// ==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-7 // @author Alplox // @match https://www.youtube.com/* // @exclude https://www.youtube.com/live_chat* // @icon https://raw.githubusercontent.com/Alplox/StartpagePlox/refs/heads/main/assets/favicon/favicon.ico // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant GM_addElement // @run-at document-end // @namespace youtube-playback-plox // @license MIT // @require https://update.greasyfork.icu/scripts/549881/1783571/YouTube%20Helper%20API.js // @downloadURL none // ==/UserScript== // ------------------------------------------ // MARK: 🔍 SISTEMA DE LOGGING // ------------------------------------------ (function () { 'use strict'; const L = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }; const level = L.silent; // Cambiar a 'debug' para ver todo, o 'warn'/'error' para menos const S = { debug: 'color:#6a9955;', info: 'color:#4FC1FF;', warn: 'color:#ce9178;font-weight:bold;', error: 'color:#f44747;font-weight:bold;' }; const noop = () => { }; const build = (t, l) => (level >= l || t === 'error') ? (c, ...a) => console[t](`%c[${c}]`, S[t], ...a) : noop; window.MyScriptLogger = { _errorLogs: [], log: build('debug', L.debug), debug: build('debug', L.debug), info: build('info', L.info), warn: (c, ...a) => { console.warn(`%c[${c}]`, S.warn, ...a); window.MyScriptLogger._internalPushLog(c, a); }, error: (c, ...a) => { console.error(`%c[${c}]`, S.error, ...a); window.MyScriptLogger._internalPushLog(c, a); }, _internalPushLog: (c, a) => { const timestamp = new Date().toISOString(); const errorDetails = a.map(arg => { if (arg instanceof Error) return arg.stack || arg.message; if (typeof arg === 'object') { try { return JSON.stringify(arg, null, 2); } catch (e) { return '[Object (Unstringifiable)]'; } } return String(arg); }).join(' '); window.MyScriptLogger._errorLogs.push(`[${timestamp}] [${c}] ${errorDetails}`.trim()); if (window.MyScriptLogger._errorLogs.length > 50) window.MyScriptLogger._errorLogs.shift(); } }; // Global Error Trackers window.addEventListener('error', (e) => { const msg = (e.message || e.error?.message || '').toLowerCase(); if (msg.includes('resizeobserver loop') || msg.includes('undelivered notifications')) { return; } if (e.filename && e.filename.includes('youtube-playback-plox')) { window.MyScriptLogger.error('Global Error', e.error || e.message); } else if (!e.filename || e.filename === '') { window.MyScriptLogger.error('DOM Error', e.error || e.message); } }); window.addEventListener('unhandledrejection', (e) => { if (e.reason && (e.reason instanceof Error) && e.reason.stack && e.reason.stack.includes('youtube-playback-plox')) { window.MyScriptLogger.error('Unhandled Promise', e.reason); } else if (e.reason && e.reason.message && e.reason.message.includes('getCascadedVideoInf')) { window.MyScriptLogger.error('Unhandled Promise', e.reason); } else if (e.reason && e.reason.stack === undefined) { window.MyScriptLogger.error('Unhandled Promise', e.reason); } }); })(); // Atajo para no tener que escribir window.MyScriptLogger cada vez const { log: logLog, info: logInfo, warn: logWarn, error: logError } = window.MyScriptLogger; // --- INICIO CARGA LÓGICA PRINCIPAL DEL USERSCRIPT --- (() => { 'use strict'; const SCRIPT_VERSION = typeof GM_info !== 'undefined' ? GM_info.script.version : '0.0.9-7'; /** * Polyfill ligero para CustomEvent en navegadores antiguos. * Crea window.CustomEvent si no existe o no es una función nativa. * @returns {void} */ (function polyfillCustomEvent() { try { if (typeof window.CustomEvent === 'function') return; } catch (_) { /* noop */ } try { function CustomEventPolyfill(event, params) { params = params || { bubbles: false, cancelable: false, detail: null }; const evt = document.createEvent('CustomEvent'); evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); return evt; } CustomEventPolyfill.prototype = (window.Event || function () { }).prototype; window.CustomEvent = CustomEventPolyfill; } catch (_) { /* noop */ } })(); // ------------------------------------------ // MARK: 🌐 Carga de Traducciones // ------------------------------------------ // URL del archivo de traducciones const TRANSLATIONS_URL = 'https://raw.githubusercontent.com/Alplox/Youtube-Playback-Plox/refs/heads/main/translations.json'; const TRANSLATIONS_URL_BACKUP = 'https://cdn.jsdelivr.net/gh/Alplox/Youtube-Playback-Plox@refs/heads/main/translations.json'; const TRANSLATIONS_EXPECTED_VERSION = SCRIPT_VERSION; // Variables globales para las traducciones let TRANSLATIONS = {}; let LANGUAGE_FLAGS = {}; // Traducciones básicas de fallback en caso de error const FALLBACK_FLAGS = { "en-US": { "emoji": "🇺🇸", "code": "en-US", "name": "English (US)" }, "es-ES": { "emoji": "🇪🇸", "code": "es-ES", "name": "Español" }, "fr": { "emoji": "🇫🇷", "code": "fr", "name": "Français" } }; const FALLBACK_TRANSLATIONS = { "en-US": { "youtubePlaybackPlox": "YouTube Playback Plox", "migrationBackupPrompt": "An update to your saved videos database has been detected. To avoid potential data loss due to a migration error, you will be prompted to save a JSON backup.", "askDownloadBackupPreMigration": "Do you want to download the backup JSON before the update proceeds?", "settings": "Settings", "savedVideos": "View saved videos", "manageVideos": "Manage videos", "viewAllHistory": "View all history", "viewCompletedVideos": "View completed videos", "completedVideos": "Completed videos", "close": "Close", "save": "Save", "saveAs": "Save as", "cancel": "Cancel", "delete": "Delete", "undo": "Undo", "clearAll": "Clear all", "clearAllConfirm": "Are you sure you want to delete ALL saved videos? This action can be undone.", "deleteEntry": "Delete entry", "deleteSelected": "Delete selected", "confirmDeleteSelected": "Are you sure you want to delete {count} videos?", "retryNow": "Retry now", "retryCompleted": "Retry completed", "playlistPrefix": "Playlist", "loading": "Loading", "progress": "Progress", "unknown": "Unknown", "deleted": "deleted.", "protect": "Protect", "unprotect": "Unprotect", "protected": "Protected", "unprotected": "Unprotected", "protectedVideos": "Protected videos", "protectedVideoWarning": "This video is protected and cannot be deleted.", "protectedItemsSkipped": "{count} protected items were skipped.", "notAvailable": "N/A", "errors": "errors", "rendered": "Rendered", "configurationSaved": "Configuration saved", "noSavedVideos": "No saved videos.", "progressSaved": "Progress saved", "errorSaving": "Error saving progress", "unknownError": "Unknown error", "language": "Language", "showFloatingButton": "Show floating button", "enableProgressBarGradient": "Enable color gradient in progress bar", "manualSaveMode": "Manual save mode", "manualSaveModeTooltip": "If enabled, progress will only be saved when clicking the save button.", "enableAutomaticSavingFor": "Enable automatic saving for", "regularVideos": "Regular videos", "miniplayerVideos": "Miniplayer videos", "shorts": "Shorts", "liveStreams": "Live streams", "inlinePreviews": "Inline previews on Home", "minSecondsBetweenSaves": "Minimum seconds between saves", "alertStyle": "Alert style in playback bar", "alertIconText": "Icon + Text", "alertIconOnly": "Icon Only", "alertTextOnly": "Text Only", "alertHidden": "Hidden", "staticFinishPercent": "Percentage to mark video as completed", "countOncePerSession": "Log additional completion times only once per session", "countOncePerSessionTooltip": "If enabled, once the completion threshold is reached, replays or auto-looping will not be counted multiple times within the same session.", "searchByTitleOrAuthor": "Search by title or author...", "advancedFilters": "Advanced Filters", "activeFilters": "{count} active filters", "custom": "Custom", "sortBy": "Sort by", "mostRecent": "Most recent", "oldest": "Oldest", "titleAZ": "Title (A-Z)", "titleZA": "Title (Z-A)", "authorAZ": "Author (A-Z)", "authorZA": "Author (Z-A)", "duration": "Duration", "durationShort": "Duration (Shortest)", "durationLong": "Duration (Longest)", "yourMostWatched": "Your most watched", "yourLeastWatched": "Your least watched", "mostViewsYoutube": "Most views on YouTube", "leastViewsYoutube": "Least views on YouTube", "progressDESC": "Progress (Most to least)", "progressASC": "Progress (Least to most)", "filterByType": "Filter by type", "all": "All", "videos": "Videos", "playlist": "Playlist", "completed": "Completed", "completedOnce": "Completed at least once", "videosWithFixedTime": "Videos with fixed time", "views": "Views", "minLimit": "Min", "maxLimit": "Max", "minViews": "Min views", "maxViews": "Max views", "minPercent": "Min %", "maxPercent": "Max %", "percentWatched": "% watched", "remaining": "remaining", "setStartTime": "Set start time", "changeOrRemoveStartTime": "Always start from {time} (Click to change or remove)", "enterStartTime": "Enter the start time you always want to use (example: 1:23)", "enterStartTimeOrEmpty": "Enter the start time you always want to use (example: 1:23) or leave empty to remove", "watchedCount": "Watched {count} times", "watchedHistory": "Watch history", "openChannel": "Open channel", "resumedAt": "Resumed at", "alwaysStartFrom": "Always start from", "startTimeSet": "Start time set to", "fixedTimeRemoved": "Fixed time removed.", "live": "Live", "previews": "Previews", "selectAllResults": "Select all current results", "deselectAllResults": "Deselect all current results", "allItemsCleared": "All items cleared", "storageFull": "Storage full - Progress cannot be saved", "allDataRestored": "All data restored", "allDataCleared": "All data cleared", "noDataToRestore": "No data to restore", "clearAllDataConfirm": "Are you sure you want to delete all data?", "itemsRestored": "{count} items restored", "migratingData": "Migrating saved data from previous version...", "migratingDataProgress": "Migrating data... {count} entries processed", "migrationComplete": "Migration completed: {migrated} videos successfully migrated", "migrationNoData": "No data found to migrate", "omitedVideos": "Omitted videos", "export": "Export", "import": "Import", "dataExported": "Data exported", "exportSelected": "Export selected", "itemsExported": "Exported {count} items", "itemsImported": "Imported {count} items", "importError": "Error importing. Make sure the file is valid.", "exportError": "Error exporting data", "invalidFormat": "Invalid format", "invalidJson": "Invalid JSON", "invalidDatabase": "Invalid database", "noValidVideos": "No valid videos found to import", "fileTooLarge": "File is too large (max {size})", "importingFromFreeTube": "Importing from FreeTube...", "importingFromFreeTubeAsSQLite": "Importing from FreeTube as SQLite...", "videosImported": "videos imported", "noVideosImported": "no videos could be imported", "noVideosFoundInFreeTubeDB": "No videos found in FreeTube database", "videosImportedFromFreeTubeDB": "videos imported from FreeTube database", "noVideosImportedFromFreeTubeDB": "no videos could be imported from FreeTube database", "fileEmpty": "File is empty", "processingFile": "Processing file...", "createPlaylist": "Create playlist", "openPlaylist": "Open playlist", "selectVideos": "Select videos", "selectedVideos": "Selected videos", "generatePlaylistLink": "Generate playlist link", "playlistLinkGenerated": "Playlist link generated", "copyLink": "Copy link", "linkCopied": "Link copied to clipboard", "removeFromPlaylist": "Remove from playlist", "confirmRemoveFromPlaylist": "Are you sure you want to remove this video from the playlist? It will remain as an individual video.", "playlistAssociationRemoved": "Playlist association removed", "selectAtLeastOne": "Select at least one video", "tooManyVideos": "Too many videos selected (max 200)", "githubBackup": "GitHub Backup", "githubToken": "Personal Access Token", "githubGistId": "Gist ID", "githubAutoBackup": "Enable automatic backup", "githubInterval": "Backup interval (hours 1-24)", "githubBackupNow": "Backup Now", "githubLastSync": "Last sync", "githubGistView": "View Gist", "githubBackupSuccess": "Backup successful", "githubBackupError": "Backup error", "githubTokenRequired": "GitHub Token required", "githubInvalidToken": "Invalid GitHub Token", "githubHelp": "How to configure?", "githubHelpStep1": "1. Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic).", "githubHelpStep2Gist": "2. Generate a new token with only the 'gist' scope.", "githubHelpStep2Repo": "2. Generate a new token with the 'repo' scope (required for private repositories).", "githubHelpStep3": "3. Paste the token generated below.", "githubHelpStep4Repo": "4. Create a private repository on your GitHub account and enter the Owner and Name below.", "githubHelpImportant": "Important: Never share your token or gist ID with anyone outside of this script.", "githubGistIdPlaceholder": "ID (empty for new)", "githubGistIdExample": "Example Gist ID: https://gist.github.com/Alplox/123456789 -> ID: 123456789", "githubSelectRepo": "Gist created/updated successfully", "githubBackupNowInfo": "This will create a backup of all saved videos in JSON format. The file will be uploaded as a secret Gist on GitHub. Keep in mind that, while it’s not public, anyone with the Gist ID could access its contents. This behavior is inherent to how GitHub Gists work and is outside the control of this userscript.", "githubRepoBackupNowInfo": "This will create a backup of all saved videos in JSON format. The file will be uploaded to your private repository as 'youtube-playback-plox-backup.json'. Your backup history will be maintained through Git commits.", "githubBackupType": "Backup storage", "githubBackupTypeGist": "GitHub Gist (Secret but not entirely private)", "githubBackupTypeRepo": "GitHub Repository (Private)", "githubRepoOwner": "Repository Owner", "githubRepoOwnerPlaceholder": "Your GitHub username", "githubRepoName": "Repository Name", "githubRepoNamePlaceholder": "E.g.: ypp-backups", "githubAutoDeleteToken": "Auto-delete token from script after manual backup", "githubGistSafe": "Gists only require 'gist' scope (minimal privilege).", "githubCleanupGuide": "Accidental Backup Cleanup", "githubCleanupStep1": "To remove data completely, you can delete the Gist or Repository directly on GitHub.", "githubCleanupStep2": "For repositories, deleting the backup file leaves history in previous commits. Deleting the entire repository is the only way to purge all traces.", "githubRepoPrivacyError": "Error: The repository must be private to perform the backup.", "githubRepoCheck": "Verifying repository privacy...", "supportLogsTitle": "Support & Error Logs", "copyLogsBtn": "Copy Logs", "reportIssue": "Report Issue", "logsCopied": "Logs copied to clipboard!", "noLogs": "No errors recorded." }, "es-ES": { "youtubePlaybackPlox": "YouTube Playback Plox", "migrationBackupPrompt": "Se ha detectado una actualización en la base de datos de videos guardados. Para evitar la posible pérdida de datos debido a un error de migración, se te pedirá que guardes una copia de seguridad en formato JSON.", "askDownloadBackupPreMigration": "¿Quieres descargar la copia de seguridad en formato JSON antes de que continúe la actualización?", "settings": "Configuración", "savedVideos": "Ver videos guardados", "manageVideos": "Gestionar vídeos", "viewAllHistory": "Ver todo el historial", "viewCompletedVideos": "Ver videos completados", "completedVideos": "Videos completados", "close": "Cerrar", "save": "Guardar", "saveAs": "Guardar como", "cancel": "Cancelar", "delete": "Eliminar", "undo": "Deshacer", "clearAll": "Eliminar todo", "clearAllConfirm": "¿Estás seguro de que quieres eliminar TODOS los videos guardados? Esta acción se puede deshacer.", "deleteEntry": "Eliminar entrada", "deleteSelected": "Eliminar seleccionados", "confirmDeleteSelected": "¿Seguro que quieres eliminar {count} vídeos?", "retryNow": "Reintentar ahora", "retryCompleted": "Reintentos completados", "playlistPrefix": "Playlist", "loading": "Cargando", "progress": "Progreso", "unknown": "Desconocido", "deleted": "eliminado.", "protect": "Proteger", "unprotect": "Quitar protección", "protected": "Protegido", "unprotected": "Sin protección", "protectedVideos": "Videos protegidos", "protectedVideoWarning": "Este video está protegido y no puede eliminarse.", "protectedItemsSkipped": "Se omitieron {count} elementos protegidos.", "notAvailable": "N/A", "errors": "errores", "rendered": "Renderizados", "configurationSaved": "Configuración guardada", "noSavedVideos": "No hay videos guardados.", "progressSaved": "Progreso guardado", "errorSaving": "Error guardando progreso", "unknownError": "Error desconocido", "language": "Idioma", "showFloatingButton": "Mostrar botón flotante", "enableProgressBarGradient": "Habilitar degradado de colores en barra de progreso", "manualSaveMode": "Modo de guardado manual", "manualSaveModeTooltip": "Si está activado, el progreso solo se guardará al pulsar el botón de guardado.", "enableAutomaticSavingFor": "Habilitar guardado automático para", "regularVideos": "Videos regulares", "miniplayerVideos": "Vídeos en minirreproductor", "shorts": "Shorts", "liveStreams": "Directos (Livestreams)", "inlinePreviews": "Previsualizaciones en la página de inicio", "minSecondsBetweenSaves": "Intervalo segundos mínimos entre guardados", "alertStyle": "Estilo de alertas en la barra de reproducción", "alertIconText": "Icono + Texto", "alertIconOnly": "Solo Icono", "alertTextOnly": "Solo Texto", "alertHidden": "Oculto", "staticFinishPercent": "Porcentaje para marcar video como completado", "countOncePerSession": "Registrar tiempos de finalización adicionales solo una vez por sesión", "countOncePerSessionTooltip": "Si está activado, una vez alcanzado el umbral de finalización, las repeticiones o la reproducción automática no se contarán varias veces dentro de la misma sesión.", "searchByTitleOrAuthor": "Buscar por título o autor...", "advancedFilters": "Filtros avanzados", "activeFilters": "{count} filtros activos", "custom": "Personalizado", "sortBy": "Ordenar por", "mostRecent": "Más recientes", "oldest": "Más antiguos", "titleAZ": "Título (A-Z)", "titleZA": "Título (Z-A)", "authorAZ": "Autor (A-Z)", "authorZA": "Autor (Z-A)", "duration": "Duración", "durationShort": "Duración (Más corta)", "durationLong": "Duración (Más larga)", "yourMostWatched": "Tus más vistos", "yourLeastWatched": "Tus menos vistos", "mostViewsYoutube": "Más vistas en YouTube", "leastViewsYoutube": "Menos vistas en YouTube", "progressDESC": "Progreso (Mayor a menor)", "progressASC": "Progreso (Menor a mayor)", "filterByType": "Filtrar por tipo", "all": "Todos", "videos": "Videos", "playlist": "Playlist", "completed": "Completado", "completedOnce": "Completado al menos una vez", "videosWithFixedTime": "Videos con tiempo fijo", "views": "Vistas", "minLimit": "Mín", "maxLimit": "Máx", "minViews": "Mín vistas", "maxViews": "Máx vistas", "minPercent": "Mín %", "maxPercent": "Máx %", "percentWatched": "% visto", "remaining": "restantes", "setStartTime": "Establecer tiempo de inicio", "changeOrRemoveStartTime": "Siempre empezar en {time} (Click para cambiar o eliminar)", "enterStartTime": "Introduce el tiempo de inicio que siempre quieres usar (ejemplo: 1:23)", "enterStartTimeOrEmpty": "Introduce el tiempo de inicio que siempre quieres usar (ejemplo: 1:23) o deja vacío para eliminar", "watchedCount": "Visto {count} veces", "watchedHistory": "Historial de visualización", "openChannel": "Abrir canal", "resumedAt": "Reanudado en", "alwaysStartFrom": "Siempre desde", "startTimeSet": "Tiempo de inicio establecido en", "fixedTimeRemoved": "Tiempo fijo eliminado.", "live": "Directo", "previews": "Previsualizaciones", "selectAllResults": "Seleccionar todos los resultados actuales", "deselectAllResults": "Deseleccionar todos los resultados actuales", "allItemsCleared": "Todos los elementos eliminados", "storageFull": "Almacenamiento lleno - No se puede guardar el progreso", "allDataRestored": "Todos los datos restaurados", "allDataCleared": "Todos los datos eliminados", "noDataToRestore": "No hay datos para restaurar", "clearAllDataConfirm": "¿Estás seguro de que quieres eliminar todos los datos?", "itemsRestored": "{count} elementos restaurados", "migratingData": "Migrando datos guardados desde versión anterior...", "migratingDataProgress": "Migrando datos... {count} entradas procesadas", "migrationComplete": "Migración completada: {migrated} videos migrados correctamente", "migrationNoData": "No se encontraron datos para migrar", "omitedVideos": "Videos omitidos", "export": "Exportar", "import": "Importar", "dataExported": "Datos exportados", "exportSelected": "Exportar seleccionados", "itemsExported": "{count} elementos exportados", "itemsImported": "Importados {count} elementos", "importError": "Error al importar. Asegúrate de que el archivo sea válido.", "exportError": "Error al exportar datos", "invalidFormat": "Formato inválido", "invalidJson": "JSON inválido", "invalidDatabase": "Base de datos inválida", "noValidVideos": "No se encontraron videos válidos para importar", "fileTooLarge": "El archivo es demasiado grande (máx {size})", "importingFromFreeTube": "Importando desde FreeTube...", "importingFromFreeTubeAsSQLite": "Importando desde FreeTube como SQLite...", "videosImported": "videos importados", "noVideosImported": "no se pudo importar ningún video", "noVideosFoundInFreeTubeDB": "No se encontraron videos en la base de datos de FreeTube", "videosImportedFromFreeTubeDB": "videos importados desde la base de datos de FreeTube", "noVideosImportedFromFreeTubeDB": "no se pudo importar ningún video desde la base de datos de FreeTube", "fileEmpty": "El archivo está vacío", "processingFile": "Procesando archivo...", "createPlaylist": "Crear playlist", "openPlaylist": "Abrir playlist", "selectVideos": "Seleccionar videos", "selectedVideos": "Videos seleccionados", "generatePlaylistLink": "Generar enlace de playlist", "playlistLinkGenerated": "Enlace de playlist generado", "copyLink": "Copiar enlace", "linkCopied": "Enlace copiado al portapapeles", "removeFromPlaylist": "Quitar de la lista de reproducción", "confirmRemoveFromPlaylist": "¿Estás seguro de que quieres quitar este vídeo de la lista de reproducción? Se mantendrá como vídeo individual.", "playlistAssociationRemoved": "Asociación de la lista de reproducción eliminada", "selectAtLeastOne": "Selecciona al menos un video", "tooManyVideos": "Demasiados videos seleccionados (máx 200)", "githubBackup": "Copia de seguridad de GitHub", "githubToken": "Token de acceso personal", "githubGistId": "ID del Gist", "githubAutoBackup": "Activar copia de seguridad automática", "githubInterval": "Intervalo de copia (horas 1-24)", "githubBackupNow": "Crear copia ahora", "githubLastSync": "Última sincronización", "githubGistView": "Ver Gist", "githubBackupSuccess": "Copia de seguridad completada", "githubBackupError": "Error en la copia de seguridad", "githubTokenRequired": "Se requiere un token de GitHub", "githubInvalidToken": "Token de GitHub no válido", "githubHelp": "¿Cómo configurarlo?", "githubHelpStep1": "1. Ve a Configuración de GitHub > Configuración de desarrollador > Tokens de acceso personal > Tokens (clásicos).", "githubHelpStep2Gist": "2. Genera un nuevo token con solo el alcance 'gist'.", "githubHelpStep2Repo": "2. Genera un nuevo token con el alcance 'repo' (necesario para repositorios privados).", "githubHelpStep3": "3. Pega el token generado abajo.", "githubHelpStep4Repo": "4. Crea un repositorio privado en tu cuenta de GitHub e introduce el propietario y el nombre abajo.", "githubHelpImportant": "Importante: Nunca compartas tu token o ID de Gist con nadie fuera de este script.", "githubGistIdPlaceholder": "ID (vacío para nuevo)", "githubGistIdExample": "Ejemplo de ID de Gist: https://gist.github.com/Alplox/123456789 -> ID: 123456789", "githubSelectRepo": "Gist creado/actualizado correctamente", "githubBackupNowInfo": "Esto creará una copia de seguridad de todos los vídeos guardados en formato JSON. El archivo se subirá como un Gist secreto en GitHub. Ten en cuenta que, aunque no es público, cualquiera con el ID del Gist puede acceder a su contenido. Este comportamiento es propio de GitHub Gists y está fuera del control de este script.", "githubRepoBackupNowInfo": "Esto creará una copia de seguridad de todos los vídeos guardados en formato JSON. El archivo se subirá a tu repositorio privado como 'youtube-playback-plox-backup.json'. El historial de copias se mantendrá mediante commits de Git.", "githubBackupType": "Almacenamiento de copia", "githubBackupTypeGist": "GitHub Gist (secreto pero no completamente privado)", "githubBackupTypeRepo": "Repositorio de GitHub (privado)", "githubRepoOwner": "Propietario del repositorio", "githubRepoOwnerPlaceholder": "Tu usuario de GitHub", "githubRepoName": "Nombre del repositorio", "githubRepoNamePlaceholder": "Ej.: ypp-backups", "githubAutoDeleteToken": "Eliminar automáticamente el token del script tras copia manual", "githubGistSafe": "Los Gists solo requieren el alcance 'gist' (privilegios mínimos).", "githubCleanupGuide": "Limpieza de copias accidentales", "githubCleanupStep1": "Para eliminar los datos completamente, puedes borrar el Gist o el repositorio directamente en GitHub.", "githubCleanupStep2": "En repositorios, eliminar el archivo deja historial en commits anteriores. Borrar todo el repositorio es la única forma de eliminar todos los rastros.", "githubRepoPrivacyError": "Error: El repositorio debe ser privado para realizar la copia.", "githubRepoCheck": "Verificando privacidad del repositorio...", "supportLogsTitle": "Soporte y registros de errores", "copyLogsBtn": "Copiar registros", "reportIssue": "Reportar problema", "logsCopied": "¡Registros copiados al portapapeles!", "noLogs": "No hay errores registrados." }, "fr": { "youtubePlaybackPlox": "YouTube Playback Plox", "migrationBackupPrompt": "Une mise à jour de la base de données des vidéos enregistrées a été détectée. Pour éviter toute perte de données due à une erreur de migration, il vous sera demandé de sauvegarder une copie de sauvegarde au format JSON.", "askDownloadBackupPreMigration": "Voulez-vous télécharger la sauvegarde au format JSON avant que la mise à jour ne continue ?", "settings": "Paramètres", "savedVideos": "Voir les vidéos enregistrées", "manageVideos": "Gérer les vidéos", "viewAllHistory": "Voir tout l'historique", "viewCompletedVideos": "Voir les vidéos terminées", "completedVideos": "Vidéos terminées", "close": "Fermer", "save": "Enregistrer", "saveAs": "Enregistrer sous", "cancel": "Annuler", "delete": "Supprimer", "undo": "Annuler", "clearAll": "Tout effacer", "clearAllConfirm": "Êtes-vous sûr de vouloir supprimer TOUTES les vidéos enregistrées ? Cette action peut être annulée.", "deleteEntry": "Supprimer l'entrée", "deleteSelected": "Supprimer la sélection", "confirmDeleteSelected": "Êtes-vous sûr de vouloir supprimer {count} vidéos ?", "retryNow": "Réessayer maintenant", "retryCompleted": "Réessais terminés", "playlistPrefix": "Playlist", "loading": "Chargement", "progress": "Progrès", "unknown": "Inconnu", "deleted": "supprimé.", "protect": "Protéger", "unprotect": "Retirer la protection", "protected": "Protégé", "unprotected": "Non protégé", "protectedVideos": "Vidéos protégées", "protectedVideoWarning": "Cette vidéo est protégée et ne peut pas être supprimée.", "protectedItemsSkipped": "{count} éléments protégés ont été ignorés.", "notAvailable": "N/A", "errors": "erreurs", "rendered": "Rendus", "configurationSaved": "Configuration enregistrée", "noSavedVideos": "Aucune vidéo enregistrée.", "progressSaved": "Progrès enregistré", "errorSaving": "Erreur lors de l'enregistrement de la progression", "unknownError": "Erreur inconnue", "language": "Langue", "showFloatingButton": "Afficher le bouton flottant", "enableProgressBarGradient": "Activer le dégradé de couleurs dans la barre de progression", "manualSaveMode": "Mode de sauvegarde manuelle", "manualSaveModeTooltip": "Si activé, la progression ne sera sauvegardée qu'en cliquant sur le bouton de sauvegarde.", "enableAutomaticSavingFor": "Activer l’enregistrement automatique pour", "regularVideos": "Vidéos régulières", "miniplayerVideos": "Vidéos en mini-lecteur", "shorts": "Shorts", "liveStreams": "Diffusions en direct", "inlinePreviews": "Aperçus intégrés sur l’accueil (Home)", "minSecondsBetweenSaves": "Secondes minimales entre les sauvegardes", "alertStyle": "Style d'alerte dans la barre de lecture", "alertIconText": "Icône + Texte", "alertIconOnly": "Icône uniquement", "alertTextOnly": "Texte uniquement", "alertHidden": "Masqué", "staticFinishPercent": "Pourcentage pour marquer la vidéo comme terminée", "countOncePerSession": "Enregistrer les complétions supplémentaires une seule fois par session", "countOncePerSessionTooltip": "Si activé, une fois le seuil de complétion atteint, les relectures ou la lecture en boucle ne seront pas comptées plusieurs fois au cours de la même session.", "searchByTitleOrAuthor": "Rechercher par titre ou auteur...", "advancedFilters": "Filtres avancés", "activeFilters": "{count} filtres actifs", "custom": "Personnalisé", "sortBy": "Trier par", "mostRecent": "Plus récent", "oldest": "Plus ancien", "titleAZ": "Titre (A-Z)", "titleZA": "Titre (Z-A)", "authorAZ": "Auteur (A-Z)", "authorZA": "Auteur (Z-A)", "duration": "Durée", "durationShort": "Durée (La plus courte)", "durationLong": "Durée (La plus longue)", "yourMostWatched": "Vos plus regardés", "yourLeastWatched": "Vos moins regardés", "mostViewsYoutube": "Le plus de vues sur YouTube", "leastViewsYoutube": "Le moins de vues sur YouTube", "progressDESC": "Progression (Du plus au moins)", "progressASC": "Progression (Du moins au plus)", "filterByType": "Filtrer par type", "all": "Tous", "videos": "Vidéos", "playlist": "Playlist", "completed": "Terminé", "completedOnce": "Complété au moins une fois", "videosWithFixedTime": "Vidéos avec un temps fixe", "views": "Vues", "minLimit": "Min", "maxLimit": "Max", "minViews": "Vues min", "maxViews": "Vues max", "minPercent": "Min %", "maxPercent": "Max %", "percentWatched": "% regardé", "remaining": "restant", "setStartTime": "Définir l'heure de début", "changeOrRemoveStartTime": "Toujours commencer à {time} (Cliquez pour changer ou supprimer)", "enterStartTime": "Entrez l'heure de début que vous souhaitez toujours utiliser (exemple: 1:23)", "enterStartTimeOrEmpty": "Entrez l'heure de début que vous souhaitez toujours utiliser (exemple: 1:23) ou laissez vide pour supprimer", "watchedCount": "Visionné {count} fois", "watchedHistory": "Historique de visionnage", "openChannel": "Ouvrir la chaîne", "resumedAt": "Repris à", "alwaysStartFrom": "Toujours commencer à", "startTimeSet": "Heure de début définie à", "fixedTimeRemoved": "Heure fixe supprimée.", "live": "Diffusion en direct", "previews": "Aperçus", "selectAllResults": "Sélectionner tous les résultats actuels", "deselectAllResults": "Désélectionner tous les résultats actuels", "allItemsCleared": "Tous les éléments effacés", "storageFull": "Stockage plein - Impossible d’enregistrer la progression", "allDataRestored": "Toutes les données restaurées", "allDataCleared": "Toutes les données ont été effacées", "noDataToRestore": "Aucune donnée à restaurer", "clearAllDataConfirm": "Êtes-vous sûr de vouloir supprimer toutes les données ?", "itemsRestored": "{count} éléments restaurés", "migratingData": "Migration des données enregistrées depuis la version précédente...", "migratingDataProgress": "Migration des données... {count} éléments traités", "migrationComplete": "Migration terminée : {migrated} vidéos migrées avec succès", "migrationNoData": "Aucune donnée trouvée à migrer", "omitedVideos": "Vidéos omises", "export": "Exporter", "import": "Importer", "dataExported": "Données exportées", "exportSelected": "Exporter la sélection", "itemsExported": "{count} éléments exportés", "itemsImported": "{count} éléments importés", "importError": "Erreur lors de l'importation. Assurez-vous que le fichier est valide.", "exportError": "Erreur lors de l'exportation des données", "invalidFormat": "Format invalide", "invalidJson": "JSON invalide", "invalidDatabase": "Base de données invalide", "noValidVideos": "Aucune vidéo valide trouvée à importer", "fileTooLarge": "Le fichier est trop volumineux (max {size})", "importingFromFreeTube": "Importation depuis FreeTube...", "importingFromFreeTubeAsSQLite": "Importation depuis FreeTube en tant que SQLite...", "videosImported": "vidéos importées", "noVideosImported": "aucune vidéo n'a pu être importée", "noVideosFoundInFreeTubeDB": "Aucune vidéo trouvée dans la base de données FreeTube", "videosImportedFromFreeTubeDB": "vidéos importées depuis la base de données FreeTube", "noVideosImportedFromFreeTubeDB": "aucune vidéo n'a pu être importée depuis la base de données FreeTube", "fileEmpty": "Le fichier est vide", "processingFile": "Traitement du fichier...", "createPlaylist": "Créer une playlist", "openPlaylist": "Ouvrir la playlist", "selectVideos": "Sélectionner des vidéos", "selectedVideos": "Vidéos sélectionnées", "generatePlaylistLink": "Générer le lien de la playlist", "playlistLinkGenerated": "Lien de la playlist généré", "copyLink": "Copier le lien", "linkCopied": "Lien copié dans le presse-papiers", "removeFromPlaylist": "Retirer de la playlist", "confirmRemoveFromPlaylist": "Êtes-vous sûr de vouloir retirer cette vidéo de la playlist ? Elle restera comme vidéo individuelle.", "playlistAssociationRemoved": "Association à la playlist supprimée", "selectAtLeastOne": "Sélectionnez au moins une vidéo", "tooManyVideos": "Trop de vidéos sélectionnées (max 200)", "githubBackup": "Sauvegarde GitHub", "githubToken": "Jeton d'accès personnel", "githubGistId": "ID du Gist", "githubAutoBackup": "Activer la sauvegarde automatique", "githubInterval": "Intervalle de sauvegarde (heures 1-24)", "githubBackupNow": "Sauvegarder maintenant", "githubLastSync": "Dernière synchronisation", "githubGistView": "Voir le Gist", "githubBackupSuccess": "Sauvegarde réussie", "githubBackupError": "Erreur de sauvegarde", "githubTokenRequired": "Jeton GitHub requis", "githubInvalidToken": "Jeton GitHub invalide", "githubHelp": "Comment configurer ?", "githubHelpStep1": "1. Allez dans Paramètres GitHub > Paramètres développeur > Jetons d'accès personnel > Jetons (classiques).", "githubHelpStep2Gist": "2. Générez un nouveau jeton avec uniquement le scope 'gist'.", "githubHelpStep2Repo": "2. Générez un nouveau jeton avec le scope 'repo' (nécessaire pour les dépôts privés).", "githubHelpStep3": "3. Collez le jeton généré ci-dessous.", "githubHelpStep4Repo": "4. Créez un dépôt privé sur votre compte GitHub et entrez le propriétaire et le nom ci-dessous.", "githubHelpImportant": "Important : Ne partagez jamais votre jeton ou l'ID du Gist avec qui que ce soit en dehors de ce script.", "githubGistIdPlaceholder": "ID (vide pour nouveau)", "githubGistIdExample": "Exemple d'ID Gist : https://gist.github.com/Alplox/123456789 -> ID : 123456789", "githubSelectRepo": "Gist créé/mis à jour avec succès", "githubBackupNowInfo": "Cela créera une sauvegarde de toutes les vidéos enregistrées au format JSON. Le fichier sera téléchargé comme un Gist secret sur GitHub. Notez que, bien qu'il ne soit pas public, toute personne disposant de l'ID du Gist peut accéder à son contenu.", "githubRepoBackupNowInfo": "Cela créera une sauvegarde de toutes les vidéos enregistrées au format JSON. Le fichier sera téléchargé dans votre dépôt privé sous le nom 'youtube-playback-plox-backup.json'. L'historique sera conservé via les commits Git.", "githubBackupType": "Stockage de sauvegarde", "githubBackupTypeGist": "GitHub Gist (secret mais pas totalement privé)", "githubBackupTypeRepo": "Dépôt GitHub (privé)", "githubRepoOwner": "Propriétaire du dépôt", "githubRepoOwnerPlaceholder": "Votre nom d'utilisateur GitHub", "githubRepoName": "Nom du dépôt", "githubRepoNamePlaceholder": "Ex. : ypp-backups", "githubAutoDeleteToken": "Supprimer automatiquement le jeton du script après sauvegarde manuelle", "githubGistSafe": "Les Gists nécessitent uniquement le scope 'gist' (privilège minimal).", "githubCleanupGuide": "Nettoyage des sauvegardes accidentelles", "githubCleanupStep1": "Pour supprimer complètement les données, vous pouvez supprimer le Gist ou le dépôt directement sur GitHub.", "githubCleanupStep2": "Pour les dépôts, supprimer le fichier laisse un historique dans les commits précédents. Supprimer le dépôt entier est la seule façon d'effacer toutes les traces.", "githubRepoPrivacyError": "Erreur : Le dépôt doit être privé pour effectuer la sauvegarde.", "githubRepoCheck": "Vérification de la confidentialité du dépôt...", "supportLogsTitle": "Support et journaux d’erreurs", "copyLogsBtn": "Copier les journaux", "reportIssue": "Signaler un problème", "logsCopied": "Journaux copiés dans le presse-papiers !", "noLogs": "Aucune erreur enregistrée." } }; // Función para cargar las traducciones desde el archivo JSON externo async function loadTranslations() { const CACHE_KEY = CONFIG.STORAGE_KEYS.translations; const TTL_MS = 6 * 60 * 60 * 1000; // 6 horas // 1) Intentar usar caché (GM_* preferido; luego localStorage) try { if (typeof GM_getValue === 'function') { const raw = await GM_getValue(CACHE_KEY, null); if (raw) { const cached = JSON.parse(raw); const isFresh = cached?.ts && (Date.now() - cached.ts) < TTL_MS; const cachedVersion = cached?.version ?? cached?.data?.VERSION; const versionMatches = !TRANSLATIONS_EXPECTED_VERSION || cachedVersion === TRANSLATIONS_EXPECTED_VERSION; if (isFresh && cached?.data && versionMatches) { logInfo('loadTranslations', 'Usando traducciones desde caché GM_*'); return cached.data; } } } } catch (_) { } try { const raw = localStorage.getItem(CACHE_KEY); if (raw) { const cached = JSON.parse(raw); const isFresh = cached?.ts && (Date.now() - cached.ts) < TTL_MS; const cachedVersion = cached?.version ?? cached?.data?.VERSION; const versionMatches = !TRANSLATIONS_EXPECTED_VERSION || cachedVersion === TRANSLATIONS_EXPECTED_VERSION; if (isFresh && cached?.data && versionMatches) { logInfo('loadTranslations', 'Usando traducciones desde caché localStorage'); return cached.data; } } } catch (_) { } // 2) Helper para cargar desde URL con GM_xmlhttpRequest o fetch const fetchUrl = async (url) => { if (typeof GM_xmlhttpRequest === 'function') { return await new Promise((resolve, reject) => { try { GM_xmlhttpRequest({ method: 'GET', url, timeout: 5000, onload: (response) => { try { resolve(JSON.parse(response.responseText)); } catch (e) { reject(e); } }, onerror: (e) => reject(e), ontimeout: () => reject(new Error('timeout')) }); } catch (err) { reject(err); } }); } // Fallback a fetch nativo if (typeof fetch === 'function') { const resp = await fetch(url, { cache: 'no-store' }); const text = await resp.text(); return JSON.parse(text); } throw new Error('No hay método de red disponible'); }; // 3) Intentar URLs primarias/secundarias const urls = [TRANSLATIONS_URL, TRANSLATIONS_URL_BACKUP]; let data = null; for (const url of urls) { try { const candidate = await fetchUrl(url); if (candidate?.LANGUAGE_FLAGS && Object.keys(candidate.LANGUAGE_FLAGS).length > 0 && candidate?.TRANSLATIONS && Object.keys(candidate.TRANSLATIONS).length > 0) { logInfo('loadTranslations', 'Traducciones externas cargadas correctamente desde: ' + url); data = candidate; break; } else { logWarn('loadTranslations', 'Traducciones inválidas desde: ' + url); } } catch (e) { logWarn('loadTranslations', 'Fallo al cargar traducciones desde ' + url, e); } } if (!data) { logError('loadTranslations', 'No se pudieron cargar traducciones externas, usando fallback'); data = { LANGUAGE_FLAGS: FALLBACK_FLAGS, TRANSLATIONS: FALLBACK_TRANSLATIONS }; } // 4) Guardar en caché const cachePayload = JSON.stringify({ ts: Date.now(), version: data?.VERSION ?? TRANSLATIONS_EXPECTED_VERSION ?? null, data }); try { if (typeof GM_setValue === 'function') await GM_setValue(CACHE_KEY, cachePayload); } catch (_) { } try { localStorage.setItem(CACHE_KEY, cachePayload); } catch (_) { } return data; } // ------------------------------------------ // MARK: 📦 Config // ------------------------------------------ const CONFIG = { /** Diferencia mínima (en segundos) para considerar un cambio de posición como válido */ minSeekDiff: 1.5, /** Claves de almacenamiento GM_setValue */ STORAGE_KEYS: { settings: 'YT_PLAYBACK_PLOX_userSettings', filters: 'YT_PLAYBACK_PLOX_userFilters', github: 'YT_PLAYBACK_PLOX_githubSettings', migration: 'YT_PLAYBACK_PLOX_migrationVersion', translations: 'YT_PLAYBACK_PLOX_translations_cache' }, /** Valores predeterminados para configuraciones del usuario */ defaultSettings: { minSecondsBetweenSaves: 1, showFloatingButtons: false, saveRegularVideos: true, // Por defecto, guardar videos regulares saveShorts: false, // Por defecto, no guardar Shorts saveLiveStreams: false, // Por defecto, no guardar directos de URL tipo "/live" o "/watch" con player en directo, si ya es VOD lo toma como regular language: 'en-US', // Idioma predeterminado alertStyle: 'iconText', // Estilo de alerta predeterminado enableProgressBarGradient: true, // Por defecto, habilitar degradado de colores en barra de progreso staticFinishPercent: 95, // Porcentaje desde el final para considerar video como completado (95% = 5% antes del final) saveInlinePreviews: false, // Guardar previsualizaciones inline (Homepage) desactivado por defecto saveMiniplayerVideos: true, // Guardar videos en miniplayer (default: activo) manualSaveMode: false, // Modo de guardado manual (default: desactivado) countOncePerSession: false, // Contar solo una vez por sesión (default: desactivado) }, alertStylesSettings: { icon_only: 'iconOnly', text_only: 'textOnly', icon_and_text: 'iconText', no_icon_no_text: 'hidden' }, defaultGithubSettings: { gist: { token: "", id: "", url: "", autoBackup: false, interval: 24, // horas lastSync: 0 }, repo: { token: "", owner: "", name: "", autoBackup: false, interval: 24, // horas lastSync: 0 }, autoDeleteToken: true, lastViewedType: 'gist' }, /** Valores predeterminados para filtros */ defaultFilters: { orderBy: "recent", filterBy: "all", searchQuery: "", minViews: 0, maxViews: 0, minPercent: 0, maxPercent: 100 } }; // MARK: Selectors // === VIDEOS /watch === // Jerarquía simplificada de YouTube Video en el DOM (acorde a url /watch): // // // └─ // └─ // └─ // └─ // └─ // └─ // └─ // └─ // 🟢 └─ // └─ // └─ -> 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): // // └─ -> Contenedor interno donde se renderiza el visor de Shorts, incluye botones de control, etc // └─ -> Irrelevante, serviria solo para primera carga de short luego queda stale (enlazado a ese primer short cargado) // └─ -> 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 // └─ // └─ -> Contenedor interno donde se renderiza el video de Shorts Activo // └─ // └─ -> id="player-container" puede exitir 3 veces en DOM; dentro de #video-preview, #masthead-player y #shorts-container // └─ id="player" Existe 2 veces en DOM; en anuncios homepage #masthead-player // 🟢 └─ -> Elemento que representa el Short activo (video actual) // └─ -> 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 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) === // -> Aqui lo importan es clase .ytdMiniplayerComponentVisible que indica que miniplayer esta activo y visible // └─ // └─ // └─ // └─ // └─ // └─ // 🟢 └─ // └─ // └─ -> Video activo // === INLINE PREVIEWS / (homepage) === // └─ // └─ // └─ // └─ // └─ // └─ // └─ // └─ // └─ // 🟢 └─ // └─ // └─ const selector = { class: c => `.${c}`, id: id => `#${id}`, attr: a => `[${a}]`, element: e => e }; // ELEMENTS (Elementos simples ) 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