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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
    });
})();