Fortnite Image Replacer

Adds a panel to replace item previews with their full-size 'featured' images or a high-resolution frame from the item's video. Caches results in IndexedDB

// ==UserScript==
// @name         Fortnite Image Replacer
// @version      1.143
// @description  Adds a panel to replace item previews with their full-size 'featured' images or a high-resolution frame from the item's video. Caches results in IndexedDB
// @match        https://fortnite.gg/*
// @grant        none
// @namespace https://greasyfork.org/users/789838
// ==/UserScript==

(function() {
    'use strict';

    // --- Глобальные константы ---
    const PROCESSED_ATTRIBUTE = 'data-processed-by-script';
    const IMAGE_SELECTOR = `.img:not([${PROCESSED_ATTRIBUTE}]), img[src*="/img/items/"]:not([${PROCESSED_ATTRIBUTE}])`;

    // --- Модуль локализации интерфейса ---
    const translations = {
        'en': { // Английский (по умолчанию)
            replaceButton: '🖼️ Replace Images',
            clearButton: '🧹 Clear Cache',
            confirmMessage: 'Are you sure you want to clear the entire image cache and reload the page?',
            clearingMessage: 'Clearing...'
        },
        'ru': { // Русский
            replaceButton: '🖼️ Заменить изображения',
            clearButton: '🧹 Очистить кеш',
            confirmMessage: 'Вы уверены, что хотите очистить весь кеш изображений и перезагрузить страницу?',
            clearingMessage: 'Очистка...'
        },
        'es': { // Испанский
            replaceButton: '🖼️ Reemplazar Imágenes',
            clearButton: '🧹 Limpiar Caché',
            confirmMessage: '¿Estás seguro de que quieres borrar toda la caché de imágenes y recargar la página?',
            clearingMessage: 'Limpiando...'
        },
        'de': { // Немецкий
            replaceButton: '🖼️ Bilder ersetzen',
            clearButton: '🧹 Cache leeren',
            confirmMessage: 'Möchten Sie den gesamten Bild-Cache wirklich leeren und die Seite neu laden?',
            clearingMessage: 'Wird gelöscht...'
        },
         'fr': { // Французский
            replaceButton: '🖼️ Remplacer les images',
            clearButton: '🧹 Vider le cache',
            confirmMessage: 'Êtes-vous sûr de vouloir vider tout le cache d\'images et recharger la page ?',
            clearingMessage: 'Vidage...'
        },
        'pt': { // Португальский
            replaceButton: '🖼️ Substituir Imagens',
            clearButton: '🧹 Limpar Cache',
            confirmMessage: 'Tem certeza de que deseja limpar todo o cache de imagens e recarregar a página?',
            clearingMessage: 'Limpando...'
        }
    };

    /**
     * Определяет язык пользователя и возвращает соответствующий объект с переводами.
     * @returns {object} - Объект с текстовыми строками на нужном языке.
     */
    function getLocale() {
        const lang = navigator.language.split('-')[0]; // Получаем основной язык (например, "ru" из "ru-RU")
        return translations[lang] || translations['en']; // Возвращаем перевод или английский по умолчанию
    }


    // --- Модуль для работы с IndexedDB (кеширование) ---
    const idb = {
        db: null,
        DB_NAME: 'FortniteImageCacheDB',
        STORE_NAME: 'ImageStore',

        async init() {
            if (this.db) return;
            return new Promise((resolve, reject) => {
                const request = indexedDB.open(this.DB_NAME, 1);
                request.onerror = () => reject('Ошибка IndexedDB: ' + request.error);
                request.onsuccess = () => { this.db = request.result; resolve(); };
                request.onupgradeneeded = (event) => {
                    const db = event.target.result;
                    if (!db.objectStoreNames.contains(this.STORE_NAME)) {
                        db.createObjectStore(this.STORE_NAME);
                    }
                };
            });
        },

        async get(key) {
            await this.init();
            return new Promise((resolve) => {
                const transaction = this.db.transaction(this.STORE_NAME, 'readonly');
                const store = transaction.objectStore(this.STORE_NAME);
                const request = store.get(key);
                request.onerror = () => resolve(undefined);
                request.onsuccess = () => resolve(request.result);
            });
        },

        async set(key, value) {
            await this.init();
            return new Promise((resolve) => {
                const transaction = this.db.transaction(this.STORE_NAME, 'readwrite');
                const store = transaction.objectStore(this.STORE_NAME);
                const request = store.put(value, key);
                request.onerror = () => resolve();
                request.onsuccess = () => resolve();
            });
        },

        async clear() {
            await this.init();
            return new Promise((resolve) => {
                const transaction = this.db.transaction(this.STORE_NAME, 'readwrite');
                const store = transaction.objectStore(this.STORE_NAME);
                const request = store.clear();
                request.onerror = () => resolve();
                request.onsuccess = () => resolve();
            });
        }
    };

    // --- Асинхронные утилиты для работы с медиа ---

    async function checkImage(url) {
        const cacheKey = 'status_' + url.split('?')[0];
        const cachedStatus = await idb.get(cacheKey);
        if (cachedStatus !== undefined) {
            return cachedStatus === 'exists';
        }
        return new Promise(resolve => {
            const img = new Image();
            img.onload = async () => { await idb.set(cacheKey, 'exists'); resolve(true); };
            img.onerror = async () => { await idb.set(cacheKey, 'missing'); resolve(false); };
            img.src = url;
        });
    }

    async function captureVideoFrame(videoUrl) {
        const cacheKey = 'frame_' + videoUrl.split('?')[0];
        const cachedFrame = await idb.get(cacheKey);
        if (cachedFrame) {
            return cachedFrame === 'failed' ? null : cachedFrame;
        }
        return new Promise(resolve => {
            const video = document.createElement('video');
            video.crossOrigin = 'anonymous';
            video.muted = true;
            const cleanup = () => video.remove();
            video.addEventListener('loadeddata', () => { video.currentTime = 2; }, { once: true });
            video.addEventListener('seeked', async () => {
                const canvas = document.createElement('canvas');
                canvas.width = video.videoWidth;
                canvas.height = video.videoHeight;
                canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
                const frameUrl = canvas.toDataURL('image/jpeg');
                await idb.set(cacheKey, frameUrl);
                resolve(frameUrl);
                cleanup();
            }, { once: true });
            video.addEventListener('error', async () => {
                await idb.set(cacheKey, 'failed');
                resolve(null);
                cleanup();
            }, { once: true });
            video.src = videoUrl;
        });
    }

    // --- Ключевая логика обработки изображений ---

    async function processImage(image) {
        if (image.dataset.processedByScript) return;
        image.dataset.processedByScript = 'true';

        try {
            const originalSrc = image.src || image.getAttribute('data-src');
            if (!originalSrc) return;

            // --- НОВАЯ ЛОГИКА: Приоритетная проверка для страницы /shop ---
            // Ищем родительскую обёртку, в стилях которой хранится качественное изображение.
            const parentWrap = image.closest('a.fn-item-wrap');
            if (parentWrap) {
                const style = window.getComputedStyle(parentWrap);
                const bgImage = style.backgroundImage;
                const bgUrlMatch = bgImage.match(/url\("?(.+?)"?\)/); // Извлекаем URL из свойства 'background-image'

                if (bgUrlMatch && bgUrlMatch[1]) {
                    const highResUrl = bgUrlMatch[1];
                    // Убеждаемся, что это реальный URL, а не градиент или пустое значение
                    if (highResUrl && highResUrl !== 'none' && !highResUrl.includes('gradient')) {
                        image.src = highResUrl;
                        return; // Завершаем обработку, так как лучшая версия найдена.
                    }
                }
            }

            // --- Старая логика (остаётся как фолбэк для других страниц) ---
            const iconMatch = originalSrc.match(/\/img\/items\/(\d+)\/icon\.(png|jpg)(\?.+)?/);
            if (iconMatch) {
                const itemId = iconMatch[1];
                const query = iconMatch[3] || '';
                let newImageUrl = null;

                const featuredUrl = `https://fortnite.gg/img/items/${itemId}/featured.png${query}`;
                if (await checkImage(featuredUrl)) {
                    newImageUrl = featuredUrl;
                } else {
                    const videoUrl = `https://fnggcdn.com/items/${itemId}/video.mp4`;
                    const frameUrl = await captureVideoFrame(videoUrl);
                    if (frameUrl) {
                        newImageUrl = frameUrl;
                    }
                }

                if (newImageUrl) {
                    image.src = newImageUrl;
                }
                return;
            }

            if (originalSrc.includes('/img/survey/') && !originalSrc.includes('/big/')) {
                const highResUrl = originalSrc.replace('/survey/', '/survey/big/');
                if (await checkImage(highResUrl)) {
                    image.src = highResUrl;
                }
            }
        } catch (error) {
            console.error('Ошибка при обработке изображения:', image.src, error);
        }
    }

    function processAllVisibleImages() {
        const imagesToProcess = document.querySelectorAll(IMAGE_SELECTOR);
        Promise.allSettled(Array.from(imagesToProcess).map(processImage));
    }

    // --- UI ---

    function addControlPanel() {
        const locale = getLocale();

        const panel = document.createElement('div');
        panel.style.cssText = 'position: fixed; top: 20px; right: 20px; background: rgba(0,0,0,0.8); padding: 15px; border-radius: 8px; z-index: 9999;';
        const buttonStyle = 'display: block; width: 100%; min-width: 150px; margin: 5px 0; padding: 10px; background: #5865F2; color: white; border: none; border-radius: 4px; cursor: pointer;';

        const replaceBtn = document.createElement('button');
        replaceBtn.textContent = locale.replaceButton;
        replaceBtn.style.cssText = buttonStyle;
        replaceBtn.onclick = processAllVisibleImages;

        const clearBtn = document.createElement('button');
        clearBtn.textContent = locale.clearButton;
        clearBtn.style.cssText = buttonStyle;
        clearBtn.onclick = async () => {
            if (confirm(locale.confirmMessage)) {
                replaceBtn.disabled = true;
                clearBtn.disabled = true;
                clearBtn.textContent = locale.clearingMessage;
                await idb.clear();
                window.location.reload();
            }
        };

        panel.appendChild(replaceBtn);
        panel.appendChild(clearBtn);
        document.body.appendChild(panel);
    }

    // --- Точка входа ---
    window.addEventListener('load', () => {
        addControlPanel();
    });
})();