8chan Spoiler Thumbnail Enhancer

Pre-sizes spoiler images, shows thumbnail (original on hover, or blurred/unblurred on hover), with dynamic settings updates via SettingsTabManager.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         8chan Spoiler Thumbnail Enhancer
// @namespace    nipah-scripts-8chan
// @version      2.5.0
// @description  Pre-sizes spoiler images, shows thumbnail (original on hover, or blurred/unblurred on hover), with dynamic settings updates via SettingsTabManager.
// @author       nipah, Gemini
// @license      MIT
// @match        https://8chan.moe/*
// @match        https://8chan.se/*
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(async function() {
    'use strict';

    // --- Configuration ---
    const SCRIPT_ID = 'SpoilerEnh'; // Unique ID for settings, attributes, classes
    const SCRIPT_VERSION = '2.2.0';
    const DEBUG_MODE = false; // Set to true for more verbose logging

    // --- Constants ---
    const DEFAULT_SETTINGS = Object.freeze({
        thumbnailMode: 'spoiler', // 'spoiler' or 'blurred'
        blurAmount: 5,            // Pixels for blur effect
        disableHoverWhenBlurred: false, // Prevent unblurring on hover in blurred mode
    });
    const GM_SETTINGS_KEY = `${SCRIPT_ID}_Settings`;

    // --- Data Attributes ---
    // Tracks the overall processing state of an image link
    const ATTR_PROCESSED_STATE = `data-${SCRIPT_ID.toLowerCase()}-processed`;
    // Tracks the state of fetching spoiler dimensions from its thumbnail
    const ATTR_DIMENSION_STATE = `data-${SCRIPT_ID.toLowerCase()}-dims-state`;
    // Stores the calculated thumbnail URL directly on the link element
    const ATTR_THUMBNAIL_URL = `data-${SCRIPT_ID.toLowerCase()}-thumb-url`;
    // Tracks if event listeners have been attached to avoid duplicates
    const ATTR_LISTENERS_ATTACHED = `data-${SCRIPT_ID.toLowerCase()}-listeners`;

    // --- CSS Classes ---
    const CLASS_REVEAL_THUMBNAIL = `${SCRIPT_ID}-revealThumbnail`; // Temporary thumbnail shown on hover (spoiler mode) or blurred preview
    const CLASS_BLUR_WRAPPER = `${SCRIPT_ID}-blurWrapper`;       // Wrapper for the blurred thumbnail to handle sizing and overflow

    // --- Selectors ---
    const SELECTORS = Object.freeze({
        // Matches standard 8chan spoiler images and common custom spoiler names
        SPOILER_IMG: `img[src="/spoiler.png"], img[src$="/custom.spoiler"]`,
        // The anchor tag wrapping the spoiler image
        IMG_LINK: 'a.imgLink',
        // Selector for the dynamically created blur wrapper div
        BLUR_WRAPPER: `.${CLASS_BLUR_WRAPPER}`,
        // Selector for the thumbnail image (used in both modes, potentially temporarily)
        REVEAL_THUMBNAIL: `img.${CLASS_REVEAL_THUMBNAIL}`, // More specific selector using tag + class
    });

    // --- Global State ---
    let scriptSettings = { ...DEFAULT_SETTINGS };

    // --- Utility Functions ---
    const log = (...args) => console.log(`[${SCRIPT_ID}]`, ...args);
    const debugLog = (...args) => DEBUG_MODE && console.log(`[${SCRIPT_ID} Debug]`, ...args);
    const warn = (...args) => console.warn(`[${SCRIPT_ID}]`, ...args);
    const error = (...args) => console.error(`[${SCRIPT_ID}]`, ...args);

    /**
     * Extracts the image hash from a full image URL.
     * @param {string | null} imageUrl The full URL of the image.
     * @returns {string | null} The extracted hash or null if parsing fails.
     */
    function getHashFromImageUrl(imageUrl) {
        if (!imageUrl) return null;
        try {
            // Prefer URL parsing for robustness
            const url = new URL(imageUrl);
            const filename = url.pathname.split('/').pop();
            if (!filename) return null;
            // Hash is typically the part before the first dot
            const hash = filename.split('.')[0];
            return hash || null;
        } catch (e) {
            // Fallback for potentially invalid URLs or non-standard paths
            warn("Could not parse image URL with URL API, falling back:", imageUrl, e);
            const parts = imageUrl.split('/');
            const filename = parts.pop();
            if (!filename) return null;
            const hash = filename.split('.')[0];
            return hash || null;
        }
    }

    /**
     * Constructs the thumbnail URL based on the full image URL and hash.
     * Assumes 8chan's '/path/to/image/HASH.ext' and '/path/to/image/t_HASH' structure.
     * @param {string | null} fullImageUrl The full URL of the image.
     * @param {string | null} hash The image hash.
     * @returns {string | null} The constructed thumbnail URL or null.
     */
    function getThumbnailUrl(fullImageUrl, hash) {
        if (!fullImageUrl || !hash) return null;
        try {
            // Prefer URL parsing
            const url = new URL(fullImageUrl);
            const pathParts = url.pathname.split('/');
            pathParts.pop(); // Remove filename
            const basePath = pathParts.join('/') + '/';
            // Construct new URL relative to the origin
            return new URL(basePath + 't_' + hash, url.origin).toString();
        } catch (e) {
            // Fallback for potentially invalid URLs
            warn("Could not construct thumbnail URL with URL API, falling back:", fullImageUrl, hash, e);
            const parts = fullImageUrl.split('/');
            parts.pop(); // Remove filename
            const basePath = parts.join('/') + '/';
            // Basic string concatenation fallback (might lack origin if relative)
            return basePath + 't_' + hash;
        }
    }

    /**
     * Validates raw settings data against defaults, ensuring correct types and ranges.
     * @param {object} settingsToValidate - The raw settings object (e.g., from GM.getValue).
     * @returns {object} A validated settings object.
     */
    function validateSettings(settingsToValidate) {
        const validated = {};
        const source = { ...DEFAULT_SETTINGS, ...settingsToValidate }; // Merge with defaults first

        validated.thumbnailMode = (source.thumbnailMode === 'spoiler' || source.thumbnailMode === 'blurred')
            ? source.thumbnailMode
            : DEFAULT_SETTINGS.thumbnailMode;

        validated.blurAmount = (typeof source.blurAmount === 'number' && source.blurAmount >= 0 && source.blurAmount <= 50) // Increased max blur slightly
            ? source.blurAmount
            : DEFAULT_SETTINGS.blurAmount;

        validated.disableHoverWhenBlurred = (typeof source.disableHoverWhenBlurred === 'boolean')
            ? source.disableHoverWhenBlurred
            : DEFAULT_SETTINGS.disableHoverWhenBlurred;

        return validated;
    }


    // --- Settings Module ---
    // Manages loading, saving, and accessing script settings.
    const Settings = {
        /** Loads settings from storage, validates them, and updates the global state. */
        async load() {
            try {
                const storedSettings = await GM.getValue(GM_SETTINGS_KEY, {});
                scriptSettings = validateSettings(storedSettings);
                log('Settings loaded:', scriptSettings);
            } catch (e) {
                warn('Failed to load settings, using defaults.', e);
                scriptSettings = { ...DEFAULT_SETTINGS }; // Reset to defaults on error
            }
        },

        /** Saves the current global settings state to storage after validation. */
        async save() {
            try {
                // Always validate before saving
                const settingsToSave = validateSettings(scriptSettings);
                await GM.setValue(GM_SETTINGS_KEY, settingsToSave);
                log('Settings saved.');
            } catch (e) {
                error('Failed to save settings.', e);
                // Consider notifying the user here if appropriate
                throw e; // Re-throw for the caller (e.g., save button handler)
            }
        },

        // --- Getters for accessing current settings ---
        getThumbnailMode: () => scriptSettings.thumbnailMode,
        getBlurAmount: () => scriptSettings.blurAmount,
        getDisableHoverWhenBlurred: () => scriptSettings.disableHoverWhenBlurred,

        // --- Setters for updating global settings state (used by UI before saving) ---
        setThumbnailMode: (mode) => { scriptSettings.thumbnailMode = mode; },
        setBlurAmount: (amount) => { scriptSettings.blurAmount = amount; },
        setDisableHoverWhenBlurred: (isDisabled) => { scriptSettings.disableHoverWhenBlurred = isDisabled; },
    };


    // --- Image Style Manipulation ---

    /**
     * Applies the current blur setting to an element.
     * @param {HTMLElement} element - The element to blur.
     */
    function applyBlur(element) {
         const blurAmount = Settings.getBlurAmount();
         element.style.filter = `blur(${blurAmount}px)`;
         element.style.willChange = 'filter'; // Hint for performance
         debugLog('Applied blur:', blurAmount, element);
    }

    /**
     * Removes blur from an element.
     * @param {HTMLElement} element - The element to unblur.
     */
    function removeBlur(element) {
         element.style.filter = 'none';
         element.style.willChange = 'auto';
         debugLog('Removed blur:', element);
    }


    // --- Image Structure Management ---

    /**
     * Fetches thumbnail dimensions and applies them to the spoiler image.
     * Avoids layout shifts by pre-sizing the spoiler placeholder.
     * @param {HTMLImageElement} spoilerImg - The original spoiler image element.
     * @param {string} thumbnailUrl - The URL of the corresponding thumbnail.
     */
    function setSpoilerDimensionsFromThumbnail(spoilerImg, thumbnailUrl) {
        // Use a more descriptive attribute name if possible, but keep current for compatibility
        const currentState = spoilerImg.getAttribute(ATTR_DIMENSION_STATE);
        if (!spoilerImg || currentState === 'success' || currentState === 'pending') {
            debugLog('Skipping dimension setting (already done or pending):', spoilerImg);
            return; // Avoid redundant work or race conditions
        }

        if (!thumbnailUrl) {
            spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-no-thumb-url');
            warn('Cannot set dimensions: no thumbnail URL provided for spoiler:', spoilerImg.closest('a')?.href);
            return;
        }

        spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'pending');
        debugLog('Attempting to set dimensions from thumbnail:', thumbnailUrl);

        const tempImg = new Image();

        const cleanup = () => {
            tempImg.removeEventListener('load', loadHandler);
            tempImg.removeEventListener('error', errorHandler);
        };

        const loadHandler = () => {
             if (tempImg.naturalWidth > 0 && tempImg.naturalHeight > 0) {
                 spoilerImg.width = tempImg.naturalWidth;  // Set explicit dimensions
                 spoilerImg.height = tempImg.naturalHeight;
                 spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'success');
                 log('Spoiler dimensions set from thumbnail:', spoilerImg.width, 'x', spoilerImg.height);
             } else {
                 warn(`Thumbnail loaded with zero dimensions: ${thumbnailUrl}`);
                 spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-zero-dim');
             }
             cleanup();
        };

        const errorHandler = (errEvent) => {
             warn(`Failed to load thumbnail for dimension setting: ${thumbnailUrl}`, errEvent);
             spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-load-error');
             cleanup();
        };

        tempImg.addEventListener('load', loadHandler);
        tempImg.addEventListener('error', errorHandler);

        try {
            // Set src to start loading
            tempImg.src = thumbnailUrl;
        } catch (e) {
            error("Error assigning src for dimension check:", thumbnailUrl, e);
            spoilerImg.setAttribute(ATTR_DIMENSION_STATE, 'failed-src-assign');
            cleanup(); // Ensure cleanup even if src assignment fails
        }
    }

    /**
     * Creates or updates the necessary DOM structure for the 'blurred' mode.
     * Hides the original spoiler and shows a blurred thumbnail.
     * @param {HTMLAnchorElement} imgLink - The parent anchor element.
     * @param {HTMLImageElement} spoilerImg - The original spoiler image.
     * @param {string} thumbnailUrl - The thumbnail URL.
     */
    function ensureBlurredStructure(imgLink, spoilerImg, thumbnailUrl) {
        let blurWrapper = imgLink.querySelector(SELECTORS.BLUR_WRAPPER);
        let revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL);

        // --- Structure Check and Cleanup ---
        // If elements exist but aren't nested correctly, remove them to rebuild
        if (revealThumbnail && (!blurWrapper || !blurWrapper.contains(revealThumbnail))) {
            debugLog('Incorrect blurred structure found, removing orphan thumbnail.');
            revealThumbnail.remove();
            revealThumbnail = null; // Reset variable
        }
        if (blurWrapper && !revealThumbnail) { // Wrapper exists but no image inside? Rebuild.
             debugLog('Incorrect blurred structure found, removing empty wrapper.');
             blurWrapper.remove();
             blurWrapper = null; // Reset variable
        }

        // --- Create or Update Structure ---
        if (!blurWrapper) {
            debugLog('Creating blur wrapper and thumbnail for:', imgLink.href);
            blurWrapper = document.createElement('div');
            blurWrapper.className = CLASS_BLUR_WRAPPER;
            blurWrapper.style.overflow = 'hidden';
            blurWrapper.style.display = 'inline-block'; // Match image display
            blurWrapper.style.lineHeight = '0';         // Prevent extra space below image
            blurWrapper.style.visibility = 'hidden';    // Hide until loaded and sized

            revealThumbnail = document.createElement('img');
            revealThumbnail.className = CLASS_REVEAL_THUMBNAIL;
            revealThumbnail.style.display = 'block'; // Ensure it fills wrapper correctly

            const cleanup = () => {
                 revealThumbnail.removeEventListener('load', loadHandler);
                 revealThumbnail.removeEventListener('error', errorHandler);
            };

            const loadHandler = () => {
                if (revealThumbnail.naturalWidth > 0 && revealThumbnail.naturalHeight > 0) {
                    const w = revealThumbnail.naturalWidth;
                    const h = revealThumbnail.naturalHeight;

                    // Set size on wrapper and image
                    blurWrapper.style.width = `${w}px`;
                    blurWrapper.style.height = `${h}px`;
                    revealThumbnail.width = w;
                    revealThumbnail.height = h;

                    applyBlur(revealThumbnail); // Apply blur *after* loading and sizing

                    blurWrapper.style.visibility = 'visible'; // Show it now
                    spoilerImg.style.display = 'none';       // Hide original spoiler
                    imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-blurred');
                    debugLog('Blurred thumbnail structure created successfully.');
                } else {
                    warn('Blurred thumbnail loaded with zero dimensions:', thumbnailUrl);
                    blurWrapper.remove();                  // Clean up failed elements
                    spoilerImg.style.display = '';         // Show spoiler again
                    imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-blurred-zero-dims');
                }
                cleanup();
            };

            const errorHandler = () => {
                warn(`Failed to load blurred thumbnail: ${thumbnailUrl}`);
                blurWrapper.remove();                      // Clean up failed elements
                spoilerImg.style.display = '';             // Show spoiler again
                imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-blurred-thumb-load');
                cleanup();
            };

            revealThumbnail.addEventListener('load', loadHandler);
            revealThumbnail.addEventListener('error', errorHandler);

            blurWrapper.appendChild(revealThumbnail);
            // Insert the wrapper before the original spoiler image
            imgLink.insertBefore(blurWrapper, spoilerImg);

            try {
                revealThumbnail.src = thumbnailUrl;
            } catch (e) {
                 error("Error assigning src to blurred thumbnail:", thumbnailUrl, e);
                 errorHandler(); // Trigger error handling manually
            }

        } else {
            // Structure exists, just ensure blur is correct and elements are visible
            debugLog('Blurred structure already exists, ensuring blur and visibility.');
            if (revealThumbnail) applyBlur(revealThumbnail); // Re-apply current blur amount
            spoilerImg.style.display = 'none';
            blurWrapper.style.display = 'inline-block';
            // Ensure state attribute reflects current mode
            imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-blurred');
        }
    }

    /**
     * Ensures the 'spoiler' mode structure is active.
     * Removes any blurred elements and ensures the original spoiler image is visible.
     * Also triggers dimension setting if needed.
     * @param {HTMLAnchorElement} imgLink - The parent anchor element.
     * @param {HTMLImageElement} spoilerImg - The original spoiler image.
     * @param {string} thumbnailUrl - The thumbnail URL (needed for dimension setting).
     */
    function ensureSpoilerStructure(imgLink, spoilerImg, thumbnailUrl) {
        const blurWrapper = imgLink.querySelector(SELECTORS.BLUR_WRAPPER);
        if (blurWrapper) {
            debugLog('Removing blurred structure for:', imgLink.href);
            blurWrapper.remove(); // Removes wrapper and its contents (revealThumbnail)
        }

        // Ensure the original spoiler image is visible
        spoilerImg.style.display = ''; // Reset to default display

        // Ensure dimensions are set (might switch before initial dimension setting completed)
        // This function has internal checks to prevent redundant work.
        setSpoilerDimensionsFromThumbnail(spoilerImg, thumbnailUrl);

        imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processed-spoiler');
        debugLog('Ensured spoiler structure for:', imgLink.href);
    }

    /**
     * Dynamically updates the visual appearance of a single image link
     * based on the current script settings (mode, blur amount).
     * This is called during initial processing and when settings change.
     * @param {HTMLAnchorElement} imgLink - The image link element to update.
     */
    function updateImageAppearance(imgLink) {
        if (!imgLink || !imgLink.matches(SELECTORS.IMG_LINK)) return;

        const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
        if (!spoilerImg) {
            // This link doesn't have a spoiler, state should reflect this
            if (!imgLink.hasAttribute(ATTR_PROCESSED_STATE)) {
                imgLink.setAttribute(ATTR_PROCESSED_STATE, 'skipped-no-spoiler');
            }
            return;
        }

        const thumbnailUrl = imgLink.getAttribute(ATTR_THUMBNAIL_URL);
        if (!thumbnailUrl) {
            // This is unexpected if processing reached this point, but handle defensively
            warn("Cannot update appearance, missing thumbnail URL attribute on:", imgLink.href);
            // Mark as failed if not already processed otherwise
             if (!imgLink.hasAttribute(ATTR_PROCESSED_STATE) || imgLink.getAttribute(ATTR_PROCESSED_STATE) === 'processing') {
                 imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-missing-thumb-attr');
             }
            return;
        }

        const currentMode = Settings.getThumbnailMode();
        debugLog(`Updating appearance for ${imgLink.href} to mode: ${currentMode}`);

        if (currentMode === 'blurred') {
            ensureBlurredStructure(imgLink, spoilerImg, thumbnailUrl);
        } else { // mode === 'spoiler'
            ensureSpoilerStructure(imgLink, spoilerImg, thumbnailUrl);
        }

        // If switching TO blurred mode OR blur amount changed while blurred, ensure blur is applied.
        // The ensureBlurredStructure function already calls applyBlur, so this check might be slightly redundant,
        // but it catches cases where the user is hovering WHILE changing settings.
        const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL);
        if (currentMode === 'blurred' && revealThumbnail) {
            // Re-apply blur in case it was removed by a hover event that hasn't triggered mouseleave yet
            applyBlur(revealThumbnail);
        }
    }


    // --- Event Handlers ---

    /** Checks if the image link's container is in an expanded state. */
    function isImageExpanded(imgLink) {
        // Find the closest ancestor figure element
        const figure = imgLink.closest('figure.uploadCell');
        // Check if the figure exists and has the 'expandedCell' class
        const isExpanded = figure && figure.classList.contains('expandedCell');
        if (isExpanded) {
             debugLog(`Image container for ${imgLink.href} is expanded.`);
        }
        return isExpanded;
    }


    /** Handles mouse entering the image link area. */
    function handleLinkMouseEnter(event) {
        const imgLink = event.currentTarget;

        // *** ADD THIS CHECK ***
        // If the image is already expanded by 8chan's logic, do nothing.
        if (isImageExpanded(imgLink)) {
            return;
        }
        // *** END CHECK ***

        const mode = Settings.getThumbnailMode();
        const thumbnailUrl = imgLink.getAttribute(ATTR_THUMBNAIL_URL);
        const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);

        // Essential elements must exist
        if (!thumbnailUrl || !spoilerImg) return;

        debugLog('Mouse Enter (Non-Expanded):', imgLink.href, 'Mode:', mode);

        if (mode === 'spoiler') {
            // Show original thumbnail temporarily
            if (imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL)) return; // Avoid duplicates

            const revealThumbnail = document.createElement('img');
            revealThumbnail.src = thumbnailUrl;
            revealThumbnail.className = CLASS_REVEAL_THUMBNAIL;
            revealThumbnail.style.display = 'block';

            if (spoilerImg.width > 0 && spoilerImg.getAttribute(ATTR_DIMENSION_STATE) === 'success') {
                 revealThumbnail.width = spoilerImg.width;
                 revealThumbnail.height = spoilerImg.height;
                 debugLog('Applying spoiler dims to hover thumb:', spoilerImg.width, spoilerImg.height);
             } else if (spoilerImg.offsetWidth > 0) {
                 revealThumbnail.style.width = `${spoilerImg.offsetWidth}px`;
                 revealThumbnail.style.height = `${spoilerImg.offsetHeight}px`;
                 debugLog('Applying spoiler offset dims to hover thumb:', spoilerImg.offsetWidth, spoilerImg.offsetHeight);
             }

            imgLink.insertBefore(revealThumbnail, spoilerImg);
            // *** IMPORTANT: Set display to none ***
            spoilerImg.style.display = 'none';

        } else if (mode === 'blurred') {
            if (Settings.getDisableHoverWhenBlurred()) return;
            const revealThumbnail = imgLink.querySelector(SELECTORS.REVEAL_THUMBNAIL);
            if (revealThumbnail) {
                removeBlur(revealThumbnail);
            }
        }
    }

    /** Handles mouse leaving the image link area. */
    function handleLinkMouseLeave(event) {
        const imgLink = event.currentTarget;

        // *** ADD THIS CHECK ***
        // If the image is already expanded by 8chan's logic, do nothing.
        // The expansion logic handles visibility, we should not interfere.
        if (isImageExpanded(imgLink)) {
            return;
        }
        // *** END CHECK ***


        const mode = Settings.getThumbnailMode();
        debugLog('Mouse Leave (Non-Expanded):', imgLink.href, 'Mode:', mode);

        if (mode === 'spoiler') {
            // Find the temporary hover thumbnail
            const revealThumbnail = imgLink.querySelector(`img.${CLASS_REVEAL_THUMBNAIL}`);

            // Only perform cleanup if the hover thumbnail exists (meaning mouseenter completed)
            if (revealThumbnail) {
                revealThumbnail.remove();

                const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
                if (spoilerImg) {
                    // Restore spoiler visibility *only if* it's currently hidden by our script
                    if (spoilerImg.style.display === 'none') {
                        debugLog('Restoring spoilerImg visibility after hover (non-expanded).');
                        spoilerImg.style.display = ''; // Reset display
                    } else {
                        debugLog('SpoilerImg display was not "none" during non-expanded mouseleave cleanup.');
                    }
                }
            }
            // If revealThumbnail wasn't found (e.g., rapid mouse out before enter completed fully),
            // we don't need to do anything, as the spoiler should still be visible.

        } else if (mode === 'blurred') {
            // Re-apply blur
            const blurredThumbnail = imgLink.querySelector(SELECTORS.BLUR_WRAPPER + ' .' + CLASS_REVEAL_THUMBNAIL);
            if (blurredThumbnail) {
                applyBlur(blurredThumbnail);
            }
        }
    }

    // --- Content Processing & Observation ---

    /**
     * Processes a single image link element if it hasn't been processed yet.
     * Fetches metadata, attaches listeners, and sets initial appearance.
     * @param {HTMLAnchorElement} imgLink - The image link element.
     */
    function processImgLink(imgLink) {
        // Check if already processed or currently processing
        if (!imgLink || imgLink.hasAttribute(ATTR_PROCESSED_STATE)) {
             // Allow re-running updateImageAppearance even if processed
             if (imgLink?.getAttribute(ATTR_PROCESSED_STATE)?.startsWith('processed-')) {
                 debugLog('Link already processed, potentially re-applying appearance:', imgLink.href);
                 updateImageAppearance(imgLink); // Ensure appearance matches current settings
             }
             return;
        }

        const spoilerImg = imgLink.querySelector(SELECTORS.SPOILER_IMG);
        if (!spoilerImg) {
            // Mark as skipped only if it wasn't processed before
            imgLink.setAttribute(ATTR_PROCESSED_STATE, 'skipped-no-spoiler');
            return;
        }

        // Mark as processing to prevent duplicate runs from observer/initial scan
        imgLink.setAttribute(ATTR_PROCESSED_STATE, 'processing');
        debugLog('Processing link:', imgLink.href);

        // --- Metadata Acquisition (Done only once) ---
        const fullImageUrl = imgLink.href;
        const hash = getHashFromImageUrl(fullImageUrl);
        if (!hash) {
            warn('Failed to get hash for:', fullImageUrl);
            imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-no-hash');
            return;
        }

        const thumbnailUrl = getThumbnailUrl(fullImageUrl, hash);
        if (!thumbnailUrl) {
            warn('Failed to get thumbnail URL for:', fullImageUrl, hash);
            imgLink.setAttribute(ATTR_PROCESSED_STATE, 'failed-no-thumb-url');
            return;
        }

        // Store the thumbnail URL on the element for easy access later
        imgLink.setAttribute(ATTR_THUMBNAIL_URL, thumbnailUrl);
        debugLog(`Stored thumb URL: ${thumbnailUrl}`);

        // --- Attach Event Listeners (Done only once) ---
        if (!imgLink.hasAttribute(ATTR_LISTENERS_ATTACHED)) {
            imgLink.addEventListener('mouseenter', handleLinkMouseEnter);
            imgLink.addEventListener('mouseleave', handleLinkMouseLeave);
            imgLink.setAttribute(ATTR_LISTENERS_ATTACHED, 'true');
            debugLog('Attached event listeners.');
        }

        // --- Set Initial Appearance based on current settings ---
        // This function also sets the final 'processed-*' state attribute
        updateImageAppearance(imgLink);

        // Dimension setting is triggered within updateImageAppearance -> ensureSpoilerStructure if needed
    }

    /**
     * Scans a container element for unprocessed spoiler image links and processes them.
     * @param {Node} container - The DOM node (usually an Element) to scan within.
     */
    function processContainer(container) {
        if (!container || typeof container.querySelectorAll !== 'function') return;

        // Select links that contain a spoiler image and are *not yet processed*
        // This selector is more specific upfront.
        const imgLinks = container.querySelectorAll(
            `${SELECTORS.IMG_LINK}:not([${ATTR_PROCESSED_STATE}]) ${SELECTORS.SPOILER_IMG}`
        );

        if (imgLinks.length > 0) {
            debugLog(`Found ${imgLinks.length} potential new spoilers in container:`, container.nodeName);
            // Get the parent link element for each found spoiler image
            imgLinks.forEach(spoiler => {
                const link = spoiler.closest(SELECTORS.IMG_LINK);
                if (link) {
                    processImgLink(link);
                } else {
                    warn("Found spoiler image without parent imgLink:", spoiler);
                }
            });
        }
         // Additionally, check links that might have failed processing previously and could be retried
         // (Example: maybe a network error prevented thumb loading before) - This might be too aggressive.
         // For now, stick to processing only newly added/unprocessed links.
    }

    // --- Settings Panel UI (STM Integration) ---

    // Cache for panel DOM elements to avoid repeated queries
    let panelElementsCache = {};

    // Unique IDs for elements within the settings panel
    const PANEL_IDS = Object.freeze({
        MODE_SPOILER: `${SCRIPT_ID}-mode-spoiler`,
        MODE_BLURRED: `${SCRIPT_ID}-mode-blurred`,
        BLUR_OPTIONS: `${SCRIPT_ID}-blur-options`,
        BLUR_AMOUNT_LABEL: `${SCRIPT_ID}-blur-amount-label`,
        BLUR_SLIDER: `${SCRIPT_ID}-blur-amount`,
        BLUR_VALUE: `${SCRIPT_ID}-blur-value`,
        DISABLE_HOVER_CHECKBOX: `${SCRIPT_ID}-disable-hover`,
        DISABLE_HOVER_LABEL: `${SCRIPT_ID}-disable-hover-label`,
        SAVE_BUTTON: `${SCRIPT_ID}-save-settings`,
        SAVE_STATUS: `${SCRIPT_ID}-save-status`,
    });

    // CSS for the settings panel (scoped via STM panel ID)
    function getSettingsPanelCSS(stmPanelId) {
        return `
        #${stmPanelId} > div { margin-bottom: 12px; }
        #${stmPanelId} label { display: inline-block; margin-right: 10px; vertical-align: middle; cursor: pointer; }
        #${stmPanelId} input[type="radio"], #${stmPanelId} input[type="checkbox"] { vertical-align: middle; margin-right: 3px; cursor: pointer; }
        #${stmPanelId} input[type="range"] { vertical-align: middle; width: 180px; margin-left: 5px; cursor: pointer; }
        #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS} { /* Use class selector for options div */
            margin-left: 20px; padding-left: 15px; border-left: 1px solid #ccc;
            margin-top: 8px; transition: opacity 0.3s ease, filter 0.3s ease;
        }
        #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS}.disabled { opacity: 0.5; filter: grayscale(50%); pointer-events: none; }
        #${stmPanelId} .${PANEL_IDS.BLUR_OPTIONS} > div { margin-bottom: 8px; }
        #${stmPanelId} #${PANEL_IDS.BLUR_VALUE} { display: inline-block; min-width: 25px; text-align: right; margin-left: 5px; font-family: monospace; font-weight: bold; }
        #${stmPanelId} button { margin-top: 15px; padding: 5px 10px; cursor: pointer; }
        #${stmPanelId} #${PANEL_IDS.SAVE_STATUS} { margin-left: 10px; font-size: 0.9em; font-style: italic; }
        #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.success { color: green; }
        #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.error { color: red; }
        #${stmPanelId} #${PANEL_IDS.SAVE_STATUS}.info { color: #555; }
    `;
    }

    // HTML structure for the settings panel
    const settingsPanelHTML = `
        <div>
            <strong>Thumbnail Mode:</strong><br>
            <input type="radio" id="${PANEL_IDS.MODE_SPOILER}" name="${SCRIPT_ID}-mode" value="spoiler">
            <label for="${PANEL_IDS.MODE_SPOILER}">Show Original Thumbnail on Hover</label><br>
            <input type="radio" id="${PANEL_IDS.MODE_BLURRED}" name="${SCRIPT_ID}-mode" value="blurred">
            <label for="${PANEL_IDS.MODE_BLURRED}">Show Blurred Thumbnail</label>
        </div>
        <div class="${PANEL_IDS.BLUR_OPTIONS}" id="${PANEL_IDS.BLUR_OPTIONS}"> <!-- Use class and ID -->
            <div>
                <label for="${PANEL_IDS.BLUR_SLIDER}" id="${PANEL_IDS.BLUR_AMOUNT_LABEL}">Blur Amount:</label>
                <input type="range" id="${PANEL_IDS.BLUR_SLIDER}" min="1" max="50" step="1"> <!-- Max 50 -->
                <span id="${PANEL_IDS.BLUR_VALUE}"></span>px
            </div>
            <div>
                <input type="checkbox" id="${PANEL_IDS.DISABLE_HOVER_CHECKBOX}">
                <label for="${PANEL_IDS.DISABLE_HOVER_CHECKBOX}" id="${PANEL_IDS.DISABLE_HOVER_LABEL}">Disable Unblur on Hover</label>
            </div>
        </div>
        <hr>
        <div>
            <button id="${PANEL_IDS.SAVE_BUTTON}">Save & Apply Settings</button>
            <span id="${PANEL_IDS.SAVE_STATUS}"></span>
        </div>`;

    /** Caches references to panel elements for quick access. */
    function cachePanelElements(panelElement) {
        panelElementsCache = { // Store references in the scoped cache
            panel: panelElement,
            modeSpoilerRadio: panelElement.querySelector(`#${PANEL_IDS.MODE_SPOILER}`),
            modeBlurredRadio: panelElement.querySelector(`#${PANEL_IDS.MODE_BLURRED}`),
            blurOptionsDiv: panelElement.querySelector(`#${PANEL_IDS.BLUR_OPTIONS}`), // Query by ID is fine here
            blurSlider: panelElement.querySelector(`#${PANEL_IDS.BLUR_SLIDER}`),
            blurValueSpan: panelElement.querySelector(`#${PANEL_IDS.BLUR_VALUE}`),
            disableHoverCheckbox: panelElement.querySelector(`#${PANEL_IDS.DISABLE_HOVER_CHECKBOX}`),
            saveButton: panelElement.querySelector(`#${PANEL_IDS.SAVE_BUTTON}`),
            saveStatusSpan: panelElement.querySelector(`#${PANEL_IDS.SAVE_STATUS}`),
        };
        // Basic check for essential elements
        if (!panelElementsCache.modeSpoilerRadio || !panelElementsCache.saveButton || !panelElementsCache.blurOptionsDiv) {
            error("Failed to cache essential panel elements. UI may not function correctly.");
            return false;
        }
        debugLog("Panel elements cached.");
        return true;
    }

    /** Updates the enabled/disabled state and appearance of blur options based on mode selection. */
    function updateBlurOptionsStateUI() {
        const elements = panelElementsCache; // Use cached elements
        if (!elements.blurOptionsDiv) return;

        const isBlurredMode = elements.modeBlurredRadio?.checked;
        const isDisabled = !isBlurredMode;

        // Toggle visual state class
        elements.blurOptionsDiv.classList.toggle('disabled', isDisabled);

        // Toggle disabled attribute for form elements
        if (elements.blurSlider) elements.blurSlider.disabled = isDisabled;
        if (elements.disableHoverCheckbox) elements.disableHoverCheckbox.disabled = isDisabled;

        debugLog("Blur options UI state updated. Disabled:", isDisabled);
    }

    /** Populates the settings controls with current values from the Settings module. */
    function populateControlsUI() {
        const elements = panelElementsCache;
        if (!elements.panel) {
             warn("Cannot populate controls, panel elements not cached/ready.");
             return;
        }

        try {
            const mode = Settings.getThumbnailMode();
            if (elements.modeSpoilerRadio) elements.modeSpoilerRadio.checked = (mode === 'spoiler');
            if (elements.modeBlurredRadio) elements.modeBlurredRadio.checked = (mode === 'blurred');

            const blurAmount = Settings.getBlurAmount();
            if (elements.blurSlider) elements.blurSlider.value = blurAmount;
            if (elements.blurValueSpan) elements.blurValueSpan.textContent = blurAmount;

            if (elements.disableHoverCheckbox) {
                elements.disableHoverCheckbox.checked = Settings.getDisableHoverWhenBlurred();
            }

            updateBlurOptionsStateUI(); // Ensure blur options state is correct on population
            debugLog("Settings panel UI populated with current settings.");

        } catch (err) {
             error("Error populating settings controls:", err);
        }
    }

    /** Sets the status message in the settings panel. */
    function setStatusMessage(message, type = 'info', duration = 3000) {
        const statusSpan = panelElementsCache.saveStatusSpan;
        if (!statusSpan) return;

        statusSpan.textContent = message;
        statusSpan.className = type; // Add class for styling (success, error, info)

        // Clear message after duration (if duration > 0)
        if (duration > 0) {
            setTimeout(() => {
                if (statusSpan.textContent === message) { // Avoid clearing newer messages
                    statusSpan.textContent = '';
                    statusSpan.className = '';
                }
            }, duration);
        }
    }

    /** Handles the click on the 'Save Settings' button in the panel. */
    async function handleSaveClickUI() {
        const elements = panelElementsCache;
        if (!elements.saveButton || !elements.modeSpoilerRadio) return;

        setStatusMessage('Saving...', 'info', 0); // Indicate saving (no timeout)

        try {
            // --- 1. Read new values from UI ---
            const newMode = elements.modeSpoilerRadio.checked ? 'spoiler' : 'blurred';
            const newBlurAmount = parseInt(elements.blurSlider.value, 10);
            const newDisableHover = elements.disableHoverCheckbox.checked;

            // Client-side validation (redundant with Settings.validate, but good UX)
            if (isNaN(newBlurAmount) || newBlurAmount < 1 || newBlurAmount > 50) {
                throw new Error(`Invalid blur amount: ${newBlurAmount}. Must be between 1 and 50.`);
            }

            // --- 2. Update settings in the Settings module ---
            // This updates the global `scriptSettings` object
            Settings.setThumbnailMode(newMode);
            Settings.setBlurAmount(newBlurAmount);
            Settings.setDisableHoverWhenBlurred(newDisableHover);

            // --- 3. Save persistently ---
            await Settings.save(); // This also validates internally

            // --- 4. Apply changes dynamically to existing elements ---
            setStatusMessage('Applying changes...', 'info', 0);
            log(`Applying settings dynamically: Mode=${newMode}, Blur=${newBlurAmount}, DisableHover=${newDisableHover}`);

            // Select all links that have been successfully processed previously
            const processedLinks = document.querySelectorAll(`a.imgLink[${ATTR_PROCESSED_STATE}^="processed-"]`);
            log(`Found ${processedLinks.length} elements to update dynamically.`);

            processedLinks.forEach(link => {
                try {
                    // This function handles switching between modes or updating blur amount
                    updateImageAppearance(link);
                } catch (updateErr) {
                    // Log error for specific link but continue with others
                    error(`Error updating appearance for ${link.href}:`, updateErr);
                }
            });

            // --- 5. Final status update ---
            setStatusMessage('Saved & Applied!', 'success', 3000);
            log('Settings saved and changes applied dynamically.');

        } catch (err) {
            error('Failed to save or apply settings:', err);
            setStatusMessage(`Error: ${err.message || 'Could not save/apply.'}`, 'error', 5000);
        }
    }

    /** Attaches event listeners to the controls *within* the settings panel. */
    function addPanelEventListeners() {
        const elements = panelElementsCache;
        if (!elements.panel) {
            error("Cannot add panel listeners, panel elements not cached.");
            return;
        }

        // Debounce function to prevent rapid firing during slider drag
        let debounceTimer;
        const debounce = (func, delay = 50) => {
            return (...args) => {
                clearTimeout(debounceTimer);
                debounceTimer = setTimeout(() => { func.apply(this, args); }, delay);
            };
        };

        // Save Button
        elements.saveButton?.addEventListener('click', handleSaveClickUI);

        // Mode Radio Buttons (update blur options enable/disable state)
        const modeChangeHandler = () => updateBlurOptionsStateUI();
        elements.modeSpoilerRadio?.addEventListener('change', modeChangeHandler);
        elements.modeBlurredRadio?.addEventListener('change', modeChangeHandler);

        // Blur Slider Input (update value display in real-time)
        elements.blurSlider?.addEventListener('input', (event) => {
            if (elements.blurValueSpan) {
                elements.blurValueSpan.textContent = event.target.value;
            }
            // Optional: Apply blur change dynamically while dragging (might be slow)
            // const applyLiveBlur = debounce(() => {
            //     if (elements.modeBlurredRadio?.checked) {
            //         Settings.setBlurAmount(parseInt(event.target.value, 10));
            //         document.querySelectorAll(`a.imgLink[${ATTR_PROCESSED_STATE}="processed-blurred"] ${SELECTORS.REVEAL_THUMBNAIL}`)
            //             .forEach(thumb => applyBlur(thumb));
            //     }
            // });
            // applyLiveBlur();
        });

        log("Settings panel event listeners added.");
    }

    // --- STM Integration Callbacks ---

    /** `onInit` callback for SettingsTabManager. Called once when the panel is first created. */
    function initializeSettingsPanel(panelElement, tabElement) {
        log(`STM initializing panel: #${panelElement.id}`);
        try {
            // Inject CSS scoped to this panel
            GM_addStyle(getSettingsPanelCSS(panelElement.id));

            // Set panel HTML content
            panelElement.innerHTML = settingsPanelHTML;

            // Cache DOM elements within the panel
            if (!cachePanelElements(panelElement)) {
                throw new Error("Failed to cache panel elements after creation.");
            }

            // Populate UI with current settings (Settings.load should have run already)
            populateControlsUI();

            // Add event listeners to the UI controls
            addPanelEventListeners();

            log('Settings panel initialized successfully.');

        } catch (err) {
             error("Error during settings panel initialization:", err);
             // Display error message within the panel itself
             panelElement.innerHTML = `<p style="color: red; border: 1px solid red; padding: 10px;">
                 Error initializing ${SCRIPT_ID} settings panel. Please check the browser console (F12) for details.
                 <br>Error: ${err.message || 'Unknown error'}
             </p>`;
        }
    }

    /** `onActivate` callback for SettingsTabManager. Called every time the tab is clicked. */
    function onSettingsTabActivate(panelElement, tabElement) {
        log(`${SCRIPT_ID} settings tab activated.`);
        // Ensure UI reflects the latest settings (in case they were changed programmatically - unlikely)
        populateControlsUI();
        // Clear any previous status messages
        setStatusMessage('', 'info', 0); // Clear immediately
    }

    // --- Main Initialization ---

    /** Sets up the script: Loads settings, registers with STM (with timeout), starts observer, processes initial content. */
    async function initialize() {
        log(`Initializing ${SCRIPT_ID} v${SCRIPT_VERSION}...`);

        // 1. Load settings first
        await Settings.load();

        // 2. Register settings panel with SettingsTabManager (with waiting logic and timeout)
        let stmAttempts = 0;
        const MAX_STM_ATTEMPTS = 20; // e.g., 20 attempts
        const STM_RETRY_DELAY_MS = 250; // Retry every 250ms
        const MAX_WAIT_TIME_MS = MAX_STM_ATTEMPTS * STM_RETRY_DELAY_MS; // ~5 seconds total wait

        function attemptStmRegistration() {
            stmAttempts++;
            debugLog(`STM check attempt ${stmAttempts}/${MAX_STM_ATTEMPTS}...`);

            // *** Check unsafeWindow directly ***
            if (typeof unsafeWindow !== 'undefined' // Ensure unsafeWindow exists
                && typeof unsafeWindow.SettingsTabManager !== 'undefined'
                && typeof unsafeWindow.SettingsTabManager.ready !== 'undefined')
            {
                log('Found SettingsTabManager on unsafeWindow. Proceeding with registration...');
                // Found it, call the async registration function, but don't wait for it here.
                // Let the rest of the script initialization continue.
                registerWithStm().catch(err => {
                     error("Async registration with STM failed after finding it:", err);
                     // Even if registration fails *after* finding STM, we proceed without the panel.
                });
                // STM found (or at least its .ready property), stop polling.
                return; // Exit the polling function
            }

            // STM not found/ready yet, check if we should give up
            if (stmAttempts >= MAX_STM_ATTEMPTS) {
                warn(`SettingsTabManager not found or not ready after ${MAX_STM_ATTEMPTS} attempts (${(MAX_WAIT_TIME_MS / 1000).toFixed(1)} seconds). Proceeding without settings panel.`);
                // Give up polling, DO NOT call setTimeout again.
                return; // Exit the polling function
            }

            // STM not found, limit not reached, schedule next attempt
            if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.SettingsTabManager !== 'undefined') {
                 debugLog('Found SettingsTabManager on unsafeWindow, but .ready property is missing. Waiting...');
            } else {
                 debugLog('SettingsTabManager not found on unsafeWindow or not ready yet. Waiting...');
            }
            setTimeout(attemptStmRegistration, STM_RETRY_DELAY_MS); // Retry after a delay
        }

        async function registerWithStm() {
            // This function now only runs if STM.ready was detected
            try {
                // *** Access via unsafeWindow ***
                 if (typeof unsafeWindow?.SettingsTabManager?.ready === 'undefined') {
                     // Should not happen if called correctly, but check defensively
                     error('SettingsTabManager.ready disappeared before registration could complete.');
                     return; // Cannot register
                 }
                const stm = await unsafeWindow.SettingsTabManager.ready;
                // *** End Access via unsafeWindow ***

                // Now register the tab using the resolved stm object
                const registrationSuccess = stm.registerTab({
                     scriptId: SCRIPT_ID,
                     tabTitle: 'Spoilers',
                     order: 30,
                     onInit: initializeSettingsPanel,
                     onActivate: onSettingsTabActivate
                });
                if (registrationSuccess) {
                    log('Successfully registered settings tab with STM.');
                } else {
                    warn('STM registration returned false (tab might already exist or other registration issue).');
                }
            } catch (err) {
                // Catch errors during the await SettingsTabManager.ready or stm.registerTab
                error('Failed to register settings tab via SettingsTabManager:', err);
                // No need to retry here, just log the failure.
            }
        }

        // Start the check/wait process *asynchronously*.
        // We don't await this; the rest of the script continues immediately.
        attemptStmRegistration();

        // 3. Set up MutationObserver (Runs regardless of STM status)
        const observerOptions = {
            childList: true,
            subtree: true
        };
        const contentObserver = new MutationObserver((mutations) => {
            const linksToProcess = new Set();
            mutations.forEach((mutation) => {
                if (mutation.addedNodes && mutation.addedNodes.length > 0) {
                    mutation.addedNodes.forEach((node) => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            if (node.matches(SELECTORS.IMG_LINK) && node.querySelector(SELECTORS.SPOILER_IMG)) {
                                linksToProcess.add(node);
                            } else {
                                node.querySelectorAll(`${SELECTORS.IMG_LINK} ${SELECTORS.SPOILER_IMG}`)
                                    .forEach(spoiler => {
                                        const link = spoiler.closest(SELECTORS.IMG_LINK);
                                        if (link) linksToProcess.add(link);
                                    });
                            }
                        }
                    });
                }
            });
            if (linksToProcess.size > 0) {
                 debugLog(`MutationObserver found ${linksToProcess.size} new potential links.`);
                 linksToProcess.forEach(link => processImgLink(link));
            }
        });
        contentObserver.observe(document.body, observerOptions);
        log('Mutation observer started.');

        // 4. Process initial content (Runs regardless of STM status)
        log('Performing initial content scan...');
        processContainer(document.body);

        log('Script initialization logic finished (STM check running in background).');
    }

    // --- Run Initialization ---
    // Use .catch here for errors during the initial synchronous part of initialize()
    // or the Settings.load() promise. Errors within async STM polling/registration
    // are handled by their respective try/catch blocks.
    initialize().catch(err => {
        error("Critical error during script initialization startup:", err);
    });

})();