Pornolab image preview - improved progressive prefetch settings debug

Preview torrent images on hover with cached topics, separate thumbnail/full-res queues, immediate foreground loads, settings UI, mouse-X navigation, and diagnostics

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name Pornolab image preview - improved progressive prefetch settings debug
// @description Preview torrent images on hover with cached topics, separate thumbnail/full-res queues, immediate foreground loads, settings UI, mouse-X navigation, and diagnostics
// @namespace https://pornolab.net/forum/index.php
// @version 1.7
// @author tobij12 - pingu2, revised
// @match https://pornolab.net/forum/tracker.php*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect *
// ==/UserScript==

(function () {
'use strict';

const DEBUG = false;
const SETTINGS_KEY = 'pornolabPreviewSettingsV1';

const DEFAULT_SETTINGS = {
    debugLogging: false,

    previewLeftPx: 350,

    defaultPreviewHeight: 400,
    minPreviewHeight: 200,

    fullUpgradeDelayMs: 100,
    minThumbVisibleMs: 500,

    useMouseXNavigation: false,

    enableTopicPrefetch: true,
    initialPrefetchCount: 6,
    hoverNearbyPrefetchRadius: 2,
    topicPrefetchDelayMs: 700,
    maxActiveTopicPrefetches: 2,
    maxTopicCacheSize: 300,

    prefetchNearbyImages: true,
    maxActiveThumbnailPrefetches: 4,
    maxActiveImagePrefetches: 2
};

const SETTING_LABELS = {
    debugLogging: 'Enable debug logging',

    previewLeftPx: 'Preview left position (px)',

    defaultPreviewHeight: 'Default preview height (px)',
    minPreviewHeight: 'Minimum preview height (px)',

    fullUpgradeDelayMs: 'Full image upgrade delay (ms)',
    minThumbVisibleMs: 'Minimum thumbnail visible time (ms)',

    useMouseXNavigation: 'Use mouse left/right movement instead of mouse wheel',

    enableTopicPrefetch: 'Enable topic page prefetch',
    initialPrefetchCount: 'Initial visible topics to prefetch',
    hoverNearbyPrefetchRadius: 'Nearby topic prefetch radius',
    topicPrefetchDelayMs: 'Initial topic prefetch delay (ms)',
    maxActiveTopicPrefetches: 'Max active topic prefetches',
    maxTopicCacheSize: 'Max topic cache size',

    prefetchNearbyImages: 'Prefetch all images for active topic',
    maxActiveThumbnailPrefetches: 'Max active thumbnail prefetches',
    maxActiveImagePrefetches: 'Max active full-res image prefetches'
};

let settings = loadSettings();

const blockedSources = [
    'vsexshop',
    'static.pornolab.net',
    'yadro',
    'vpipi',
    '9bee784100d7c6108d51fd70e9b79a50.gif',
    'nodrink',
    'rimg',
    '9ac78b9bb3e82339391d223a64daf18f',
    '4f9a8a86a785326a0a3d1560404a6fdc',
    '73ea7145a1b7d011589c849c5391c7b6',
    '5772513e239a6a8ee48af36544313a06'
];

const topicCache = new Map();

const resolvedImageCache = new Map();
const resolvedImageValueCache = new Map();

const decodedImageCache = new Map();
const decodedImageValueCache = new Map();

const stateByLink = new WeakMap();
const knownLinks = new Set();

let closeOnScrollScheduled = false;

const topicPrefetchQueue = [];
const queuedTopicPrefetchUrls = new Set();
let activeTopicPrefetches = 0;

const thumbnailPrefetchQueue = [];
const queuedThumbnailPrefetchKeys = new Set();
let activeThumbnailPrefetches = 0;

const fullImagePrefetchQueue = [];
const queuedFullImagePrefetchKeys = new Set();
let activeFullImagePrefetches = 0;

injectStyles();

GM_registerMenuCommand('Pornolab Preview Settings', openSettingsPanel);
GM_registerMenuCommand('Pornolab Preview Status', showPreviewStatus);

const links = Array.from(document.querySelectorAll('.med .tLink'));

log('Script started', {
    settings,
    detectedLinks: links.length,
    url: location.href
});

document.addEventListener('keydown', function (e) {
    if (e.key === 'Escape') {
        closeAllPreviews();
    }

    if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'p') {
        e.preventDefault();
        openSettingsPanel();
    }
});

window.addEventListener('scroll', scheduleCloseAllPreviewsOnScroll, { passive: true });
document.addEventListener('scroll', scheduleCloseAllPreviewsOnScroll, { passive: true, capture: true });

window.addEventListener('blur', closeAllPreviews);

document.addEventListener('visibilitychange', function () {
    if (document.hidden) {
        closeAllPreviews();
    }
});

links.forEach(setupLink);
scheduleInitialTopicPrefetch();

function loadSettings() {
    const saved = GM_getValue(SETTINGS_KEY, {});

    return {
        ...DEFAULT_SETTINGS,
        ...(saved || {})
    };
}

function saveSettings(nextSettings) {
    settings = {
        ...DEFAULT_SETTINGS,
        ...nextSettings
    };

    GM_setValue(SETTINGS_KEY, settings);
}

function resetSettings() {
    settings = { ...DEFAULT_SETTINGS };
    GM_setValue(SETTINGS_KEY, settings);
}

function showPreviewStatus() {
    const status = [
        'Pornolab Preview Status',
        '',
        `Settings loaded: ${JSON.stringify(settings, null, 2)}`,
        '',
        `Detected topic links: ${links.length}`,
        '',
        `Topic cache size: ${topicCache.size}`,
        `Resolved image value cache size: ${resolvedImageValueCache.size}`,
        `Decoded image value cache size: ${decodedImageValueCache.size}`,
        '',
        `Topic prefetch queue: ${topicPrefetchQueue.length}`,
        `Active topic prefetches: ${activeTopicPrefetches}`,
        `Queued topic URLs: ${queuedTopicPrefetchUrls.size}`,
        '',
        `Thumbnail prefetch queue: ${thumbnailPrefetchQueue.length}`,
        `Active thumbnail prefetches: ${activeThumbnailPrefetches}`,
        `Queued thumbnail keys: ${queuedThumbnailPrefetchKeys.size}`,
        '',
        `Full image prefetch queue: ${fullImagePrefetchQueue.length}`,
        `Active full image prefetches: ${activeFullImagePrefetches}`,
        `Queued full image keys: ${queuedFullImagePrefetchKeys.size}`
    ].join('\n');

    console.log(status);
    alert(status);
}

function openSettingsPanel() {
    if (document.querySelector('#pornolab-preview-settings')) return;

    const overlay = document.createElement('div');
    overlay.id = 'pornolab-preview-settings';

    overlay.style.cssText = `
        position: fixed;
        inset: 0;
        z-index: 100000;
        background: rgba(0, 0, 0, 0.65);
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    `;

    const panel = document.createElement('div');
    panel.style.cssText = `
        width: 620px;
        max-width: calc(100vw - 40px);
        max-height: calc(100vh - 40px);
        background: #1f1f1f;
        color: #eee;
        border-radius: 10px;
        box-shadow: 0 12px 40px rgba(0,0,0,0.7);
        overflow: hidden;
        display: flex;
        flex-direction: column;
    `;

    const header = document.createElement('div');
    header.style.cssText = `
        padding: 14px 18px;
        border-bottom: 1px solid #333;
        display: flex;
        align-items: center;
        justify-content: space-between;
        background: #252525;
    `;

    const title = document.createElement('div');
    title.textContent = 'Pornolab Preview Settings';
    title.style.cssText = `
        font-size: 17px;
        font-weight: 650;
    `;

    const closeButton = document.createElement('button');
    closeButton.textContent = '×';
    closeButton.style.cssText = `
        border: none;
        background: transparent;
        color: #ccc;
        font-size: 28px;
        line-height: 1;
        cursor: pointer;
        padding: 0 4px;
    `;
    closeButton.onclick = () => overlay.remove();

    header.appendChild(title);
    header.appendChild(closeButton);

    const body = document.createElement('div');
    body.style.cssText = `
        padding: 16px 18px;
        overflow: auto;
    `;

    const form = document.createElement('form');

    appendSectionTitle(form, 'Diagnostics');
    appendBooleanSetting(form, 'debugLogging');

    appendSectionTitle(form, 'Preview position and size');
    appendNumberSetting(form, 'previewLeftPx', 0, 3000, 10);
    appendNumberSetting(form, 'defaultPreviewHeight', 100, 2000, 25);
    appendNumberSetting(form, 'minPreviewHeight', 50, 1000, 25);

    appendSectionTitle(form, 'Thumbnail → full image upgrade');
    appendNumberSetting(form, 'fullUpgradeDelayMs', 0, 5000, 25);
    appendNumberSetting(form, 'minThumbVisibleMs', 0, 5000, 25);

    appendSectionTitle(form, 'Image navigation');
    appendBooleanSetting(form, 'useMouseXNavigation');

    appendSectionTitle(form, 'Topic page prefetch');
    appendBooleanSetting(form, 'enableTopicPrefetch');
    appendNumberSetting(form, 'initialPrefetchCount', 0, 100, 1);
    appendNumberSetting(form, 'hoverNearbyPrefetchRadius', 0, 20, 1);
    appendNumberSetting(form, 'topicPrefetchDelayMs', 0, 10000, 100);
    appendNumberSetting(form, 'maxActiveTopicPrefetches', 1, 20, 1);
    appendNumberSetting(form, 'maxTopicCacheSize', 10, 5000, 10);

    appendSectionTitle(form, 'Image prefetch');
    appendBooleanSetting(form, 'prefetchNearbyImages');
    appendNumberSetting(form, 'maxActiveThumbnailPrefetches', 1, 40, 1);
    appendNumberSetting(form, 'maxActiveImagePrefetches', 1, 20, 1);

    body.appendChild(form);

    const note = document.createElement('div');
    note.textContent = 'Thumbnail and full-res image prefetching use separate background queues. The actively viewed image bypasses both queues and starts immediately.';
    note.style.cssText = `
        color: #aaa;
        font-size: 12px;
        margin-top: 12px;
        line-height: 1.4;
    `;
    body.appendChild(note);

    const footer = document.createElement('div');
    footer.style.cssText = `
        padding: 12px 18px;
        border-top: 1px solid #333;
        display: flex;
        gap: 10px;
        justify-content: flex-end;
        background: #252525;
    `;

    const resetButton = document.createElement('button');
    resetButton.type = 'button';
    resetButton.textContent = 'Reset defaults';
    resetButton.style.cssText = buttonCss('#444');
    resetButton.onclick = () => {
        if (!confirm('Reset all Pornolab preview settings to defaults?')) return;
        resetSettings();
        overlay.remove();
        location.reload();
    };

    const saveButton = document.createElement('button');
    saveButton.type = 'button';
    saveButton.textContent = 'Save & reload';
    saveButton.style.cssText = buttonCss('#3f7fd9');
    saveButton.onclick = () => {
        const next = { ...settings };

        for (const key of Object.keys(DEFAULT_SETTINGS)) {
            const input = form.querySelector(`[name="${key}"]`);
            if (!input) continue;

            if (typeof DEFAULT_SETTINGS[key] === 'boolean') {
                next[key] = input.checked;
            } else {
                const parsed = Number(input.value);
                next[key] = Number.isFinite(parsed) ? parsed : DEFAULT_SETTINGS[key];
            }
        }

        saveSettings(next);
        overlay.remove();
        location.reload();
    };

    footer.appendChild(resetButton);
    footer.appendChild(saveButton);

    panel.appendChild(header);
    panel.appendChild(body);
    panel.appendChild(footer);

    overlay.appendChild(panel);
    document.body.appendChild(overlay);

    overlay.addEventListener('click', e => {
        if (e.target === overlay) overlay.remove();
    });
}

function appendSectionTitle(form, text) {
    const section = document.createElement('div');
    section.textContent = text;
    section.style.cssText = `
        margin: 18px 0 10px;
        padding-top: 10px;
        border-top: 1px solid #333;
        color: #ffcc66;
        font-size: 13px;
        font-weight: 700;
        text-transform: uppercase;
        letter-spacing: 0.04em;
    `;
    form.appendChild(section);
}

function appendNumberSetting(form, key, min, max, step) {
    const row = createSettingRow(key);

    const input = document.createElement('input');
    input.name = key;
    input.type = 'number';
    input.min = String(min);
    input.max = String(max);
    input.step = String(step);
    input.value = String(settings[key]);
    input.style.cssText = inputCss();

    row.appendChild(input);
    form.appendChild(row);
}

function appendBooleanSetting(form, key) {
    const row = createSettingRow(key);

    const input = document.createElement('input');
    input.name = key;
    input.type = 'checkbox';
    input.checked = !!settings[key];
    input.style.cssText = `
        width: 18px;
        height: 18px;
        accent-color: #3f7fd9;
    `;

    row.appendChild(input);
    form.appendChild(row);
}

function createSettingRow(key) {
    const row = document.createElement('label');
    row.style.cssText = `
        display: grid;
        grid-template-columns: 1fr 150px;
        align-items: center;
        gap: 12px;
        margin-bottom: 10px;
        padding: 8px 10px;
        border-radius: 6px;
        background: #292929;
    `;

    const text = document.createElement('div');
    text.textContent = SETTING_LABELS[key] || key;
    text.style.cssText = `
        font-size: 13px;
        color: #eee;
    `;

    row.appendChild(text);
    return row;
}

function inputCss() {
    return `
        width: 140px;
        box-sizing: border-box;
        background: #111;
        color: #fff;
        border: 1px solid #555;
        border-radius: 4px;
        padding: 5px 7px;
        font-size: 13px;
    `;
}

function buttonCss(background) {
    return `
        border: none;
        background: ${background};
        color: #fff;
        padding: 8px 13px;
        border-radius: 6px;
        cursor: pointer;
        font-weight: 600;
    `;
}

function setupLink(el) {
    const state = getState(el);
    knownLinks.add(el);

    const row = el.closest('tr');

    if (row) {
        row.addEventListener('pointerenter', function () {
            if (!settings.enableTopicPrefetch) return;

            const topicUrl = normalizeUrl(el.href, location.href);

            log('Row pointerenter prefetch trigger', { topicUrl });

            queueTopicPrefetch(topicUrl, 100, 'row-pointerenter-current');
            prefetchNearbyTopics(el);
        });
    }

    el.addEventListener('mouseenter', async function (event) {
        state.opened = true;
        state.index = 0;
        state.requestToken++;

        state.mouseStartX = event.clientX;
        state.mouseCurrentX = event.clientX;

        const bounds = calculateMouseNavBounds(el, event.clientX);
        state.mouseNavLeftX = bounds.left;
        state.mouseNavRightX = bounds.right;

        const token = state.requestToken;
        const topicUrl = normalizeUrl(el.href, location.href);

        log('Link mouseenter', {
            topicUrl,
            useMouseXNavigation: settings.useMouseXNavigation
        });

        prefetchNearbyTopics(el);
        showSpinner(el, 'topic page');

        try {
            const images = await getTopicImagesCached(topicUrl);

            if (!isCurrent(el, token)) return;

            state.images = images;

            if (!images.length) {
                showMessage(el, 'No preview images found');
                return;
            }

            if (settings.useMouseXNavigation) {
                state.index = getIndexFromMouseX(state.mouseCurrentX, state, images.length);
            } else {
                state.index = 0;
            }

            showImageAtCurrentIndex(el, token);
        } catch (err) {
            log('Topic load failed on hover', { topicUrl, err });

            if (isCurrent(el, token)) {
                showMessage(el, 'Preview load failed');
            }
        }
    });

    el.addEventListener('wheel', async function (e) {
        if (!state.opened) return;

        if (settings.useMouseXNavigation) {
            log('Wheel ignored because mouse-X navigation is enabled');
            return;
        }

        e.preventDefault();
        e.stopPropagation();

        const token = ++state.requestToken;

        clearFullUpgradeTimer(state);

        try {
            if (!state.images) {
                const topicUrl = normalizeUrl(el.href, location.href);
                state.images = await getTopicImagesCached(topicUrl);
            }

            if (!state.images.length) return;

            if (e.deltaY < 0) {
                state.index = Math.max(0, state.index - 1);
            } else if (e.deltaY > 0) {
                state.index = Math.min(state.images.length - 1, state.index + 1);
            }

            showImageAtCurrentIndex(el, token);
        } catch (err) {
            log('Wheel image load failed', err);
        }

        return false;
    }, { passive: false });

    if (row) {
        row.addEventListener('mousemove', function (event) {
            if (!settings.useMouseXNavigation) return;
            if (!state.opened) return;

            state.mouseCurrentX = event.clientX;

            if (!state.images || state.images.length <= 1) return;

            const nextIndex = getIndexFromMouseX(event.clientX, state, state.images.length);

            if (nextIndex === state.index) return;

            state.index = nextIndex;

            const token = ++state.requestToken;
            clearFullUpgradeTimer(state);

            log('Mouse-X image index changed', {
                index: nextIndex,
                total: state.images.length,
                x: event.clientX,
                left: state.mouseNavLeftX,
                right: state.mouseNavRightX
            });

            showImageAtCurrentIndex(el, token);
        });

        row.addEventListener('mouseout', function (event) {
            const related = event.relatedTarget;

            if (related && row.contains(related)) return;

            closePreview(el);
        });

        row.addEventListener('mouseleave', function () {
            closePreview(el);
        });
    }
}

function getState(el) {
    let state = stateByLink.get(el);

    if (!state) {
        state = {
            opened: false,
            index: 0,
            requestToken: 0,
            images: null,
            fullLoadTimer: null,
            previewContainer: null,

            mouseStartX: 0,
            mouseCurrentX: 0,
            mouseNavLeftX: 0,
            mouseNavRightX: 0
        };

        stateByLink.set(el, state);
    }

    return state;
}

function isCurrent(el, token) {
    const state = getState(el);
    return state.opened && state.requestToken === token;
}

function calculateMouseNavBounds(el, startX) {
    const topicCell =
        el.closest('td.row4.tLeft') ||
        el.closest('td') ||
        el.closest('tr') ||
        el;

    const rect = topicCell.getBoundingClientRect();

    const left = startX;
    const minimumRangePx = 180;
    const rightPaddingPx = 20;

    const right = Math.max(
        rect.right - rightPaddingPx,
        startX + minimumRangePx
    );

    return { left, right };
}

function getIndexFromMouseX(clientX, state, totalImages) {
    if (!totalImages || totalImages <= 1) return 0;

    const left = state.mouseNavLeftX;
    const right = state.mouseNavRightX;

    if (!Number.isFinite(left) || !Number.isFinite(right) || right <= left) {
        return 0;
    }

    const t = clamp((clientX - left) / (right - left), 0, 1);

    return Math.round(t * (totalImages - 1));
}

function clamp(value, min, max) {
    return Math.min(max, Math.max(min, value));
}

function scheduleInitialTopicPrefetch() {
    if (!settings.enableTopicPrefetch) {
        log('Initial topic prefetch disabled');
        return;
    }

    setTimeout(() => {
        const visibleLinks = links
            .filter(isElementNearViewport)
            .slice(0, settings.initialPrefetchCount);

        log('Initial topic prefetch starting', {
            visibleCount: visibleLinks.length,
            initialPrefetchCount: settings.initialPrefetchCount,
            delayMs: settings.topicPrefetchDelayMs
        });

        visibleLinks.forEach((link, i) => {
            const topicUrl = normalizeUrl(link.href, location.href);
            queueTopicPrefetch(topicUrl, settings.initialPrefetchCount - i, 'initial');
        });
    }, settings.topicPrefetchDelayMs);
}

function prefetchNearbyTopics(currentEl) {
    if (!settings.enableTopicPrefetch) return;

    const currentIndex = links.indexOf(currentEl);
    if (currentIndex < 0) return;

    for (let offset = -settings.hoverNearbyPrefetchRadius; offset <= settings.hoverNearbyPrefetchRadius; offset++) {
        if (offset === 0) continue;

        const link = links[currentIndex + offset];
        if (!link) continue;

        const topicUrl = normalizeUrl(link.href, location.href);
        const priority = settings.hoverNearbyPrefetchRadius - Math.abs(offset);

        queueTopicPrefetch(topicUrl, priority, 'nearby-hover');
    }
}

function queueTopicPrefetch(topicUrl, priority = 0, reason = 'unknown') {
    if (!settings.enableTopicPrefetch || !topicUrl) {
        log('Topic prefetch skipped', {
            reason,
            enabled: settings.enableTopicPrefetch,
            topicUrl
        });
        return;
    }

    if (topicCache.has(topicUrl)) {
        log('Topic prefetch skipped: already cached/fetching', {
            reason,
            topicUrl
        });
        return;
    }

    if (queuedTopicPrefetchUrls.has(topicUrl)) {
        log('Topic prefetch skipped: already queued', {
            reason,
            topicUrl
        });
        return;
    }

    queuedTopicPrefetchUrls.add(topicUrl);
    topicPrefetchQueue.push({ topicUrl, priority, reason });
    topicPrefetchQueue.sort((a, b) => b.priority - a.priority);

    log('Topic prefetch queued', {
        reason,
        priority,
        topicUrl,
        queueLength: topicPrefetchQueue.length
    });

    runTopicPrefetchQueue();
}

function runTopicPrefetchQueue() {
    while (
        activeTopicPrefetches < settings.maxActiveTopicPrefetches &&
        topicPrefetchQueue.length
    ) {
        const job = topicPrefetchQueue.shift();
        activeTopicPrefetches++;

        log('Topic prefetch started', {
            reason: job.reason,
            topicUrl: job.topicUrl,
            activeTopicPrefetches,
            maxActiveTopicPrefetches: settings.maxActiveTopicPrefetches
        });

        getTopicImagesCached(job.topicUrl)
            .then(images => {
                log('Topic prefetch finished', {
                    reason: job.reason,
                    topicUrl: job.topicUrl,
                    imageCount: images.length
                });
            })
            .catch(err => {
                log('Topic prefetch failed', {
                    reason: job.reason,
                    topicUrl: job.topicUrl,
                    error: err
                });
            })
            .finally(() => {
                activeTopicPrefetches--;
                queuedTopicPrefetchUrls.delete(job.topicUrl);
                runTopicPrefetchQueue();
            });
    }
}

function isElementNearViewport(el) {
    const rect = el.getBoundingClientRect();
    const margin = 600;

    return rect.bottom >= -margin && rect.top <= window.innerHeight + margin;
}

function showImageAtCurrentIndex(el, token) {
    const state = getState(el);
    const images = state.images || [];

    clearFullUpgradeTimer(state);
    removeTopicLoadingNode(state);

    if (!images.length) return;

    const item = images[state.index];
    if (!item || !item.thumbUrl) return;

    const index = state.index;
    const total = images.length;
    const position = calculatePreviewPosition(el);
    const hostLabel = getHostLabel(item.thumbUrl, item.parentUrl);
    const thumbShownAt = performance.now();

    /*
     * If the current image was queued in the background, remove it from queues.
     * The visible image gets a foreground/immediate path instead.
     */
    removeThumbnailPrefetchJob(item);
    removeFullImagePrefetchJob(item);

    const cachedFullInfo = getCachedDecodedFullImageInfo(item);

    if (cachedFullInfo) {
        log('Showing cached full image immediately', {
            index,
            total,
            src: cachedFullInfo.src
        });

        showPreview(el, {
            src: cachedFullInfo.src,
            index,
            total,
            height: position.height,
            top: position.top,
            loadingText: '',
            showSpinner: false,
            immediate: true
        });

        if (settings.prefetchNearbyImages) {
            prefetchAllTopicImages(images, index);
        }

        return;
    }

    showPreview(el, {
        src: item.thumbUrl,
        index,
        total,
        height: position.height,
        top: position.top,
        loadingText: `Preview — upgrading from ${hostLabel}...`,
        showSpinner: true,
        immediate: true
    });

    /*
     * Foreground thumbnail decode.
     * This bypasses thumbnail queue slots.
     */
    const thumbInfoPromise = loadCurrentThumbnailImmediate(item).catch(() => null);

    if (settings.prefetchNearbyImages) {
        prefetchAllTopicImages(images, index);
    }

    state.fullLoadTimer = setTimeout(async function () {
        if (!isCurrent(el, token)) return;
        if (getState(el).index !== index) return;

        try {
            /*
             * Foreground full-res load.
             * This bypasses full-res queue slots.
             */
            const fullResult = await loadCurrentFullImageImmediate(item);

            if (!isCurrent(el, token)) return;
            if (getState(el).index !== index) return;

            if (!fullResult || !fullResult.src) {
                hideSpinner(el);
                return;
            }

            const thumbInfo = await thumbInfoPromise;
            const fullInfo = fullResult.info;

            if (!isCurrent(el, token)) return;
            if (getState(el).index !== index) return;

            if (isBetterImage(fullInfo, thumbInfo)) {
                await waitForMinimumThumbTime(thumbShownAt);

                if (!isCurrent(el, token)) return;
                if (getState(el).index !== index) return;

                showPreview(el, {
                    src: fullInfo.src,
                    index,
                    total,
                    height: position.height,
                    top: position.top,
                    loadingText: '',
                    showSpinner: false,
                    immediate: true
                });
            } else {
                showPreview(el, {
                    src: item.thumbUrl,
                    index,
                    total,
                    height: position.height,
                    top: position.top,
                    loadingText: '',
                    showSpinner: false,
                    immediate: true
                });
            }
        } catch (err) {
            log('Immediate full image upgrade failed', err);

            if (isCurrent(el, token)) {
                hideSpinner(el);
            }
        }
    }, settings.fullUpgradeDelayMs);
}

async function loadCurrentThumbnailImmediate(item) {
    if (!item || !item.thumbUrl) return null;

    const key = getThumbnailCacheKey(item);
    removeThumbnailPrefetchJob(item);

    log('Immediate thumbnail load started', { key });

    const info = await loadAndDecodeImageCached(item.thumbUrl);

    log('Immediate thumbnail load finished', {
        key,
        decoded: !!info,
        width: info?.width,
        height: info?.height
    });

    return info;
}

async function loadCurrentFullImageImmediate(item) {
    if (!item || !item.thumbUrl) return null;

    const key = getImageCacheKey(item);
    removeFullImagePrefetchJob(item);

    log('Immediate full image load started', { key });

    const result = await prefetchHighResImage(item);

    log('Immediate full image load finished', {
        key,
        status: result.status,
        src: result.src,
        width: result.info?.width,
        height: result.info?.height
    });

    return result;
}

function getCachedDecodedFullImageInfo(item) {
    if (!item || !item.thumbUrl) return null;

    const cacheKey = getImageCacheKey(item);
    const finalSrc = resolvedImageValueCache.get(cacheKey);

    if (!finalSrc) return null;

    return decodedImageValueCache.get(finalSrc) || null;
}

function isBetterImage(candidate, current) {
    if (!candidate || !candidate.width || !candidate.height) return false;
    if (!current || !current.width || !current.height) return true;

    const candidateArea = candidate.width * candidate.height;
    const currentArea = current.width * current.height;

    return candidateArea > currentArea * 1.15;
}

function wait(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

async function waitForMinimumThumbTime(thumbShownAt) {
    const elapsed = performance.now() - thumbShownAt;
    const remaining = settings.minThumbVisibleMs - elapsed;

    if (remaining > 0) {
        await wait(remaining);
    }
}

function prefetchAllTopicImages(images, currentIndex) {
    if (!settings.prefetchNearbyImages) return;
    if (!Array.isArray(images) || !images.length) return;

    let thumbQueued = 0;
    let fullQueued = 0;

    const jobs = images
        .map((item, index) => ({
            item,
            index,
            distance: Math.abs(index - currentIndex)
        }))
        .sort((a, b) => {
            if (a.distance !== b.distance) return a.distance - b.distance;
            return a.index - b.index;
        });

    for (const job of jobs) {
        if (!job.item || !job.item.thumbUrl) continue;

        const priority = Math.max(0, images.length - job.distance);

        /*
         * Current visible item uses the immediate foreground path.
         * Do not queue it.
         */
        if (job.index !== currentIndex) {
            if (queueThumbnailPrefetch(job.item, priority, `topic-thumb:index-${job.index}`)) {
                thumbQueued++;
            }

            if (queueFullImagePrefetch(job.item, priority, `topic-full:index-${job.index}`)) {
                fullQueued++;
            }
        }
    }

    log('Aggressive topic image prefetch queued', {
        currentIndex,
        totalImages: images.length,
        thumbnailNewlyQueued: thumbQueued,
        fullNewlyQueued: fullQueued,
        activeThumbnailPrefetches,
        activeFullImagePrefetches,
        thumbnailQueueLength: thumbnailPrefetchQueue.length,
        fullQueueLength: fullImagePrefetchQueue.length,
        maxActiveThumbnailPrefetches: settings.maxActiveThumbnailPrefetches,
        maxActiveImagePrefetches: settings.maxActiveImagePrefetches
    });
}

function queueThumbnailPrefetch(item, priority, reason = 'unknown') {
    if (!settings.prefetchNearbyImages) return false;
    if (!item || !item.thumbUrl) return false;

    const key = getThumbnailCacheKey(item);

    if (decodedImageValueCache.has(item.thumbUrl)) return false;
    if (queuedThumbnailPrefetchKeys.has(key)) return false;

    queuedThumbnailPrefetchKeys.add(key);
    thumbnailPrefetchQueue.push({ item, priority, key, reason });
    thumbnailPrefetchQueue.sort((a, b) => b.priority - a.priority);

    log('Thumbnail prefetch queued', {
        reason,
        priority,
        key,
        queueLength: thumbnailPrefetchQueue.length
    });

    runThumbnailPrefetchQueue();
    return true;
}

function runThumbnailPrefetchQueue() {
    while (
        activeThumbnailPrefetches < settings.maxActiveThumbnailPrefetches &&
        thumbnailPrefetchQueue.length
    ) {
        const job = thumbnailPrefetchQueue.shift();
        activeThumbnailPrefetches++;

        log('Thumbnail prefetch started', {
            reason: job.reason,
            key: job.key,
            activeThumbnailPrefetches,
            maxActiveThumbnailPrefetches: settings.maxActiveThumbnailPrefetches
        });

        loadAndDecodeImageCached(job.item.thumbUrl)
            .then(info => {
                log('Thumbnail prefetch finished', {
                    reason: job.reason,
                    key: job.key,
                    decoded: !!info,
                    width: info?.width,
                    height: info?.height
                });
            })
            .catch(err => {
                log('Thumbnail prefetch failed', {
                    reason: job.reason,
                    key: job.key,
                    error: err
                });
            })
            .finally(() => {
                activeThumbnailPrefetches--;
                queuedThumbnailPrefetchKeys.delete(job.key);
                runThumbnailPrefetchQueue();
            });
    }
}

function queueFullImagePrefetch(item, priority, reason = 'unknown') {
    if (!settings.prefetchNearbyImages) return false;
    if (!item || !item.thumbUrl) return false;

    const key = getImageCacheKey(item);

    if (isFullImageAlreadyDecoded(item)) return false;
    if (queuedFullImagePrefetchKeys.has(key)) return false;

    queuedFullImagePrefetchKeys.add(key);
    fullImagePrefetchQueue.push({ item, priority, key, reason });
    fullImagePrefetchQueue.sort((a, b) => b.priority - a.priority);

    log('Full image prefetch queued', {
        reason,
        priority,
        key,
        queueLength: fullImagePrefetchQueue.length
    });

    runFullImagePrefetchQueue();
    return true;
}

function runFullImagePrefetchQueue() {
    while (
        activeFullImagePrefetches < settings.maxActiveImagePrefetches &&
        fullImagePrefetchQueue.length
    ) {
        const job = fullImagePrefetchQueue.shift();
        activeFullImagePrefetches++;

        log('Full image prefetch started', {
            reason: job.reason,
            key: job.key,
            activeFullImagePrefetches,
            maxActiveImagePrefetches: settings.maxActiveImagePrefetches
        });

        prefetchHighResImage(job.item)
            .then(result => {
                log('Full image prefetch finished', {
                    reason: job.reason,
                    key: job.key,
                    status: result.status,
                    src: result.src,
                    width: result.info?.width,
                    height: result.info?.height
                });
            })
            .catch(err => {
                log('Full image prefetch failed', {
                    reason: job.reason,
                    key: job.key,
                    error: err
                });
            })
            .finally(() => {
                activeFullImagePrefetches--;
                queuedFullImagePrefetchKeys.delete(job.key);
                runFullImagePrefetchQueue();
            });
    }
}

async function prefetchHighResImage(item) {
    const cacheKey = getImageCacheKey(item);

    const cachedSrc = resolvedImageValueCache.get(cacheKey);
    if (cachedSrc && decodedImageValueCache.has(cachedSrc)) {
        return {
            status: 'already-decoded',
            src: cachedSrc,
            info: decodedImageValueCache.get(cachedSrc)
        };
    }

    const finalSrc = await resolveDisplayImageUrlCached(item);

    if (!finalSrc) {
        return {
            status: 'no-final-src',
            src: null,
            info: null
        };
    }

    const info = await loadAndDecodeImageCached(finalSrc);

    if (!info) {
        return {
            status: 'decode-failed',
            src: finalSrc,
            info: null
        };
    }

    return {
        status: finalSrc === item.thumbUrl ? 'decoded-resolved-same-as-thumb' : 'decoded-highres',
        src: finalSrc,
        info
    };
}

function isFullImageAlreadyDecoded(item) {
    const cacheKey = getImageCacheKey(item);
    const finalSrc = resolvedImageValueCache.get(cacheKey);

    if (!finalSrc) return false;

    return decodedImageValueCache.has(finalSrc);
}

function removeThumbnailPrefetchJob(item) {
    if (!item || !item.thumbUrl) return;

    const key = getThumbnailCacheKey(item);
    const before = thumbnailPrefetchQueue.length;

    for (let i = thumbnailPrefetchQueue.length - 1; i >= 0; i--) {
        if (thumbnailPrefetchQueue[i].key === key) {
            thumbnailPrefetchQueue.splice(i, 1);
        }
    }

    if (before !== thumbnailPrefetchQueue.length) {
        queuedThumbnailPrefetchKeys.delete(key);
        log('Removed queued thumbnail job for immediate foreground load', { key });
    }
}

function removeFullImagePrefetchJob(item) {
    if (!item || !item.thumbUrl) return;

    const key = getImageCacheKey(item);
    const before = fullImagePrefetchQueue.length;

    for (let i = fullImagePrefetchQueue.length - 1; i >= 0; i--) {
        if (fullImagePrefetchQueue[i].key === key) {
            fullImagePrefetchQueue.splice(i, 1);
        }
    }

    if (before !== fullImagePrefetchQueue.length) {
        queuedFullImagePrefetchKeys.delete(key);
        log('Removed queued full image job for immediate foreground load', { key });
    }
}


function scheduleCloseAllPreviewsOnScroll() {
    if (closeOnScrollScheduled) return;

    closeOnScrollScheduled = true;

    requestAnimationFrame(() => {
        closeOnScrollScheduled = false;
        closeAllPreviews();
    });
}

function closePreview(el) {
    const state = getState(el);

    state.opened = false;
    state.index = 0;
    state.requestToken++;

    clearFullUpgradeTimer(state);
    removePreviewNode(state);
}

function closeAllPreviews() {
    knownLinks.forEach(closePreview);
    document.querySelectorAll('.appendedHoverImgContainer').forEach(n => n.remove());
}

function clearFullUpgradeTimer(state) {
    if (state.fullLoadTimer) {
        clearTimeout(state.fullLoadTimer);
        state.fullLoadTimer = null;
    }
}

function removePreviewNode(state) {
    if (state.previewContainer) {
        state.previewContainer.remove();
        state.previewContainer = null;
    }
}

function removeTopicLoadingNode(state) {
    if (
        state.previewContainer &&
        state.previewContainer.classList.contains('topic-loading-container')
    ) {
        removePreviewNode(state);
    }
}

function getTopicImagesCached(topicUrl) {
    if (topicCache.has(topicUrl)) {
        log('Topic cache hit / existing promise', { topicUrl });
        return topicCache.get(topicUrl);
    }

    log('Topic fetch started', { topicUrl });

    const promise = fetchText(topicUrl)
        .then(html => {
            const images = parseTopicImages(html, topicUrl);

            log('Topic fetch parsed', {
                topicUrl,
                imageCount: images.length
            });

            return images;
        })
        .catch(err => {
            log('Topic fetch failed', {
                topicUrl,
                error: err
            });

            topicCache.delete(topicUrl);
            throw err;
        });

    topicCache.set(topicUrl, promise);
    trimMap(topicCache, settings.maxTopicCacheSize);

    return promise;
}

function parseTopicImages(html, topicUrl) {
    const doc = new DOMParser().parseFromString(html, 'text/html');

    const root = doc.querySelector('.post_body') || doc;
    const candidates = Array.from(root.querySelectorAll('var.postImg, .postImg'));
    const images = [];

    for (const node of candidates) {
        const thumbUrlRaw = node.getAttribute('title') || node.title || '';
        if (!thumbUrlRaw) continue;

        const thumbUrl = normalizeUrl(thumbUrlRaw, topicUrl);
        if (!thumbUrl) continue;

        if (isBlockedSource(thumbUrl)) continue;

        const parentAnchor = node.closest('a');

        const parentUrl = parentAnchor
            ? normalizeUrl(parentAnchor.getAttribute('href') || parentAnchor.href, topicUrl)
            : null;

        const insideLikelyContent =
            node.closest('.row1') ||
            node.closest('.post_body') ||
            root.classList?.contains('post_body');

        if (!insideLikelyContent) continue;

        images.push({
            thumbUrl,
            parentUrl
        });
    }

    return dedupeImages(images);
}

function dedupeImages(images) {
    const seen = new Set();
    const result = [];

    for (const item of images) {
        const key = getImageCacheKey(item);
        if (seen.has(key)) continue;

        seen.add(key);
        result.push(item);
    }

    return result;
}

function isBlockedSource(url) {
    return blockedSources.some(blocked => url.includes(blocked));
}

function getThumbnailCacheKey(item) {
    return `thumb:${item.thumbUrl}`;
}

function getImageCacheKey(item) {
    return `${item.thumbUrl}|${item.parentUrl || ''}`;
}

function resolveDisplayImageUrlCached(item) {
    const cacheKey = getImageCacheKey(item);

    if (resolvedImageValueCache.has(cacheKey)) {
        return Promise.resolve(resolvedImageValueCache.get(cacheKey));
    }

    if (resolvedImageCache.has(cacheKey)) {
        return resolvedImageCache.get(cacheKey);
    }

    const promise = resolveDisplayImageUrl(item)
        .then(finalSrc => {
            if (finalSrc) {
                resolvedImageValueCache.set(cacheKey, finalSrc);
            }

            return finalSrc;
        })
        .catch(err => {
            resolvedImageCache.delete(cacheKey);
            throw err;
        });

    resolvedImageCache.set(cacheKey, promise);

    return promise;
}

async function resolveDisplayImageUrl(item) {
    const validSource = item.thumbUrl;
    const parentUrl = item.parentUrl;

    if (!validSource) return null;

    if (validSource.includes('.gif')) {
        return validSource;
    }

    if (validSource.includes('imgbox') && validSource.includes('thumb')) {
        return validSource
            .replace('thumbs', 'images')
            .replace('_t', '_o');
    }

    if (validSource.includes('imgdrive') && validSource.includes('small')) {
        return validSource.replace('small', 'big');
    }

    if (validSource.includes('freescreens.ru') && validSource.includes('thumb')) {
        return validSource
            .replace('freescreens.', 'picforall.')
            .replace('-thumb', '');
    }

    if (validSource.includes('fastpic.org') && parentUrl) {
        return fetchFastpicBigImageObjectUrlCached(parentUrl);
    }

    if (validSource.includes('imgbox') && parentUrl) {
        return fetchHostImageObjectUrlCached(parentUrl);
    }

    if (validSource.includes('imagevenue.com') && validSource.includes('thumbs') && parentUrl) {
        return fetchHostImageObjectUrlCached(parentUrl);
    }

    if (validSource.includes('turboimg.net') && validSource.includes('/t/') && parentUrl) {
        return fetchHostImageObjectUrlCached(parentUrl);
    }

    if (validSource.includes('picshick') && validSource.includes('/th/') && parentUrl) {
        return fetchHostImageObjectUrlCached(parentUrl);
    }

    if (validSource.includes('picturelol') && validSource.includes('/th/') && parentUrl) {
        return fetchHostImageObjectUrlCached(parentUrl);
    }

    return validSource;
}

function fetchFastpicBigImageObjectUrlCached(viewPageUrl) {
    const key = `fastpic:${viewPageUrl}`;

    if (resolvedImageValueCache.has(key)) {
        return Promise.resolve(resolvedImageValueCache.get(key));
    }

    if (resolvedImageCache.has(key)) {
        return resolvedImageCache.get(key);
    }

    const promise = fetchFastpicBigImageObjectUrl(viewPageUrl)
        .then(finalSrc => {
            if (finalSrc) {
                resolvedImageValueCache.set(key, finalSrc);
            }

            return finalSrc;
        })
        .catch(err => {
            resolvedImageCache.delete(key);
            throw err;
        });

    resolvedImageCache.set(key, promise);

    return promise;
}

function fetchHostImageObjectUrlCached(viewPageUrl) {
    const key = `host:${viewPageUrl}`;

    if (resolvedImageValueCache.has(key)) {
        return Promise.resolve(resolvedImageValueCache.get(key));
    }

    if (resolvedImageCache.has(key)) {
        return resolvedImageCache.get(key);
    }

    const promise = fetchHostImageObjectUrl(viewPageUrl)
        .then(finalSrc => {
            if (finalSrc) {
                resolvedImageValueCache.set(key, finalSrc);
            }

            return finalSrc;
        })
        .catch(err => {
            resolvedImageCache.delete(key);
            throw err;
        });

    resolvedImageCache.set(key, promise);

    return promise;
}

async function fetchFastpicBigImageObjectUrl(viewPageUrl) {
    const response = await gmRequest({
        method: 'GET',
        url: viewPageUrl,
        responseType: 'blob'
    });

    const contentType = getContentType(response);

    if (contentType.includes('image/')) {
        return URL.createObjectURL(response.response);
    }

    if (!contentType.includes('text/html')) {
        return null;
    }

    const html = await blobToText(response.response);
    const doc = new DOMParser().parseFromString(html, 'text/html');

    const images = Array.from(doc.querySelectorAll('img'));

    const validImage = images.find(img =>
        img.src.includes('md5=') && img.src.includes('expires=')
    );

    if (!validImage) return null;

    return fetchImageAsObjectUrl(validImage.src);
}

async function fetchHostImageObjectUrl(viewPageUrl) {
    const response = await gmRequest({
        method: 'GET',
        url: viewPageUrl,
        responseType: 'blob'
    });

    const contentType = getContentType(response);

    if (contentType.includes('image/')) {
        return URL.createObjectURL(response.response);
    }

    if (!contentType.includes('text/html')) {
        return null;
    }

    const html = await blobToText(response.response);
    const doc = new DOMParser().parseFromString(html, 'text/html');

    const images = Array.from(doc.querySelectorAll('img'));

    const validImage = images.find(img => {
        const src = img.src || '';

        return (
            (src.includes('turboimg.net') && src.includes('/sp/')) ||
            src.includes('cdn-images.imagevenue') ||
            src.includes('picshick.com/i') ||
            src.includes('picturelol.com/i') ||
            src.includes('images2.imgbox.com') ||
            src.includes('images.imgbox.com')
        );
    });

    if (!validImage) return null;

    return fetchImageAsObjectUrl(validImage.src);
}

async function fetchImageAsObjectUrl(url) {
    const response = await gmRequest({
        method: 'GET',
        url,
        responseType: 'blob'
    });

    if (!response.response) return null;

    return URL.createObjectURL(response.response);
}

function loadAndDecodeImageCached(src) {
    if (!src) return Promise.resolve(null);

    if (decodedImageValueCache.has(src)) {
        return Promise.resolve(decodedImageValueCache.get(src));
    }

    if (decodedImageCache.has(src)) {
        return decodedImageCache.get(src);
    }

    const promise = loadAndDecodeImage(src)
        .then(info => {
            if (info) {
                decodedImageValueCache.set(src, info);
            }

            return info;
        })
        .catch(err => {
            decodedImageCache.delete(src);
            throw err;
        });

    decodedImageCache.set(src, promise);

    return promise;
}

function loadAndDecodeImage(src) {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.decoding = 'async';

        img.onload = async function () {
            try {
                if (img.decode) {
                    await img.decode();
                }
            } catch {
                // decode() can reject even when image is usable.
            }

            resolve({
                src,
                width: img.naturalWidth || img.width || 0,
                height: img.naturalHeight || img.height || 0
            });
        };

        img.onerror = reject;
        img.src = src;
    });
}

function fetchText(url) {
    return gmRequest({
        method: 'GET',
        url,
        responseType: 'text',
        headers: {
            Referer: url,
            'User-Agent': navigator.userAgent
        }
    }).then(response => response.responseText || response.response || '');
}

function gmRequest(options) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: options.method || 'GET',
            url: options.url,
            responseType: options.responseType,
            headers: options.headers || undefined,
            timeout: options.timeout || 30000,
            onload: response => {
                if (response.status >= 200 && response.status < 400) {
                    resolve(response);
                } else {
                    reject(new Error(`HTTP ${response.status} for ${options.url}`));
                }
            },
            ontimeout: () => reject(new Error(`Timeout for ${options.url}`)),
            onerror: err => reject(err)
        });
    });
}

function getContentType(response) {
    const headers = (response.responseHeaders || '').toLowerCase();

    const line = headers
        .split(/\r?\n/)
        .find(header => header.startsWith('content-type:'));

    return line || '';
}

function blobToText(blob) {
    if (!blob) return Promise.resolve('');

    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = () => resolve(reader.result || '');
        reader.onerror = reject;
        reader.readAsText(blob);
    });
}

function showPreview(el, options) {
    const container = getPreviewContainer(el);

    container.style.left = `${settings.previewLeftPx}px`;
    container.style.top = `${options.top}px`;
    container.style.height = `${options.height}px`;

    const img = container.querySelector('.preview-img');
    const label = container.querySelector('.imageCounterLabel');
    const spinnerRow = container.querySelector('.preview-loading');

    label.textContent = `Image ${options.index + 1}/${options.total}`;

    if (options.showSpinner) {
        spinnerRow.style.display = 'flex';
        spinnerRow.querySelector('.spinner-text').textContent = options.loadingText || '';
    } else {
        spinnerRow.style.display = 'none';
    }

    setPreviewImage(img, options.src, !!options.immediate);
}

function getPreviewContainer(el) {
    const state = getState(el);

    if (state.previewContainer && !state.previewContainer.classList.contains('topic-loading-container')) {
        return state.previewContainer;
    }

    removePreviewNode(state);

    const container = document.createElement('div');
    container.className = 'appendedHoverImgContainer';

    container.style.cssText = `
        position: fixed;
        left: ${settings.previewLeftPx}px;
        top: 0;
        height: ${settings.defaultPreviewHeight}px;
        width: auto;
        z-index: 9999;
        pointer-events: none;
    `;

    const img = document.createElement('img');
    img.className = 'preview-img';
    img.decoding = 'async';
    img.loading = 'eager';
    img.style.cssText = `
        height: 100%;
        width: auto;
        display: block;
        background: rgba(0, 0, 0, 0.25);
        box-shadow: 0 4px 20px rgba(0,0,0,0.45);
        transition: opacity 120ms ease-in-out;
    `;

    const label = document.createElement('div');
    label.className = 'imageCounterLabel';
    label.style.cssText = `
        position: absolute;
        top: 5px;
        left: 5px;
        color: #fff;
        font-weight: normal;
        font-family: verdana, sans-serif;
        background-color: rgba(0, 0, 0, 0.6);
        padding: 2px 6px;
        font-size: 12px;
        border-radius: 4px;
        pointer-events: none;
    `;

    const loading = document.createElement('div');
    loading.className = 'preview-loading';
    loading.style.cssText = `
        position: absolute;
        left: 5px;
        bottom: 5px;
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 3px 6px;
        border-radius: 4px;
        background: rgba(0,0,0,0.6);
    `;

    const spinner = document.createElement('div');
    spinner.className = 'spinner';

    const spinnerText = document.createElement('span');
    spinnerText.className = 'spinner-text';

    loading.appendChild(spinner);
    loading.appendChild(spinnerText);

    container.appendChild(img);
    container.appendChild(label);
    container.appendChild(loading);

    document.body.appendChild(container);
    state.previewContainer = container;

    return container;
}

function setPreviewImage(img, src, immediate = false) {
    if (!src) return;
    if (img.dataset.currentSrc === src) return;

    img.dataset.currentSrc = src;

    if (immediate) {
        img.style.opacity = '1';
        img.src = src;
        return;
    }

    img.style.opacity = '0.65';

    const next = new Image();

    next.onload = function () {
        img.src = src;

        requestAnimationFrame(() => {
            img.style.opacity = '1';
        });
    };

    next.onerror = function () {
        img.style.opacity = '1';
    };

    next.src = src;
}

function hideSpinner(el) {
    const state = getState(el);
    const container = state.previewContainer;
    if (!container) return;

    const spinnerRow = container.querySelector('.preview-loading');

    if (spinnerRow) {
        spinnerRow.style.display = 'none';
    }
}

function showSpinner(el, host) {
    const state = getState(el);

    if (host === 'topic page') {
        removePreviewNode(state);

        const position = calculatePreviewPosition(el);

        const container = document.createElement('div');
        container.className = 'appendedHoverImgContainer topic-loading-container';

        container.style.cssText = `
            position: fixed;
            left: ${settings.previewLeftPx}px;
            top: ${position.top}px;
            z-index: 9999;
            pointer-events: none;
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 5px 8px;
            border-radius: 4px;
            background: rgba(0,0,0,0.75);
            color: #eee;
            font-family: sans-serif;
            font-size: 13px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.45);
        `;

        const spinner = document.createElement('div');
        spinner.className = 'spinner';

        const text = document.createElement('span');
        text.className = 'spinner-text';
        text.textContent = 'Loading topic page...';

        container.appendChild(spinner);
        container.appendChild(text);

        document.body.appendChild(container);
        state.previewContainer = container;

        return;
    }

    const position = calculatePreviewPosition(el);

    showPreview(el, {
        src: transparentPixel(),
        index: 0,
        total: 1,
        height: position.height,
        top: position.top,
        loadingText: host ? `Loading from ${host}...` : 'Loading...',
        showSpinner: true,
        immediate: true
    });
}

function showMessage(el, message) {
    const position = calculatePreviewPosition(el);
    const container = getPreviewContainer(el);

    container.style.left = `${settings.previewLeftPx}px`;
    container.style.top = `${position.top}px`;
    container.style.height = '32px';

    const img = container.querySelector('.preview-img');
    const label = container.querySelector('.imageCounterLabel');
    const spinnerRow = container.querySelector('.preview-loading');

    setPreviewImage(img, transparentPixel(), true);
    label.textContent = message;
    spinnerRow.style.display = 'none';
}

function calculatePreviewPosition(el) {
    const rect = el.getBoundingClientRect();
    const y = rect.top;
    const windowHeight = window.innerHeight;

    let height = settings.defaultPreviewHeight;
    let topMargin = 15;

    if (windowHeight < y + 1800 - 15) {
        height = windowHeight - y - 45;

        if (height < settings.minPreviewHeight) {
            height = settings.minPreviewHeight;
            topMargin = windowHeight - y - 225;
        }
    }

    let top = y + topMargin;

    if (top + height > windowHeight - 10) {
        top = Math.max(10, windowHeight - height - 10);
    }

    if (top < 10) {
        top = 10;
    }

    return { height, top };
}

function normalizeUrl(url, baseUrl) {
    if (!url) return null;

    try {
        return new URL(url, baseUrl || location.href).href;
    } catch {
        return null;
    }
}

function getHostLabel(thumbUrl, parentUrl) {
    try {
        if (parentUrl) return new URL(parentUrl).hostname;
        return new URL(thumbUrl).hostname;
    } catch {
        return 'host';
    }
}

function trimMap(map, maxSize) {
    while (map.size > maxSize) {
        const firstKey = map.keys().next().value;
        map.delete(firstKey);
    }
}

function injectStyles() {
    const style = document.createElement('style');

    style.textContent = `
        .spinner {
            border: 5px solid rgba(255,255,255,0.18);
            border-left-color: #aaa;
            border-radius: 50%;
            width: 14px;
            height: 14px;
            animation: pornolabPreviewSpin 1s linear infinite;
            flex: 0 0 auto;
        }

        .spinner-text {
            font-size: 13px;
            color: #eee;
            font-weight: normal;
            font-family: sans-serif;
            white-space: nowrap;
        }

        @keyframes pornolabPreviewSpin {
            to { transform: rotate(360deg); }
        }
    `;

    document.head.appendChild(style);
}

function transparentPixel() {
    return 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
}

function log(...args) {
    if (settings.debugLogging || DEBUG) {
        console.log('[Pornolab preview]', ...args);
    }
}
})();