HentaiNexus Infinite Reader

Ultimate reading experience for HentaiNexus with overlay reading, webtoon and manga modes, zoom control, page gap options, theme picker, page jump, native page sync, and low-memory loading.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         HentaiNexus Infinite Reader
// @name:ja      HentaiNexus 無限スクロールリーダー
// @name:zh-CN   HentaiNexus 无限滚动阅读器
// @namespace    https://hentainexus.com/
// @version      2.0
// @author       L1Z4RD + upgraded
// @license      MIT
// @match        https://hentainexus.com/read/*
// @run-at       document-idle
// @grant        none
// @description  Ultimate reading experience for HentaiNexus with overlay reading, webtoon and manga modes, zoom control, page gap options, theme picker, page jump, native page sync, and low-memory loading.
// @description:ja HentaiNexus向けの究極の読書体験。オーバーレイリーダー、縦スクロール・漫画表示、ズーム調整、ページ間隔、テーマカラー、ページ移動、ネイティブページ同期、低メモリ読み込みに対応。
// @description:zh-CN 为 HentaiNexus 打造的终极阅读体验,支持覆盖式阅读器、条漫/漫画模式、缩放控制、页面间距、主题颜色选择、跳转页面、原生页面同步和低内存加载。
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        fullMaxWidth: 1800,
        loadBuffer: 6,
        unloadBuffer: 10,
        rootMargin: '1200px',
        maxRetries: 3,
        menuCollapseMs: 5000,
        longPressMs: 450,
        wheelLockMs: 360,
        defaultThemeColor: '#353a45',
        defaultReadingMode: 'webtoon',
        defaultGap: 5,
        defaultZoom: 0
    };

    const IDS = {
        style: 'hnir-overlay-style',
        launcher: 'hnir-open-book-launcher',
        overlay: 'hnir-overlay',
        reader: 'hnir-overlay-reader',
        uiRoot: 'hnir-overlay-ui-root',
        progressBar: 'hnir-progress-bar',
        pageIndicator: 'hnir-page-indicator'
    };

    const STORAGE = {
        settings: 'hnir-overlay-settings-v25',
        progress: 'hnir-overlay-progress:' + location.pathname
    };

    const ICONS = {
        openBook: '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 5.5c2.8 0 5 .7 8 2.5v12c-3-1.8-5.2-2.5-8-2.5z"></path><path d="M20 5.5c-2.8 0-5 .7-8 2.5v12c3-1.8 5.2-2.5 8-2.5z"></path><path d="M12 8v12"></path></svg>',
        closedBook: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6.5 4.5h10A2.5 2.5 0 0 1 19 7v13H8a3 3 0 0 1-3-3V6a1.5 1.5 0 0 1 1.5-1.5z"></path><path d="M8 4.5V20"></path><path d="M8 17h11"></path></svg>',
        menu: '<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true"><path d="M5 7h14"></path><path d="M5 12h14"></path><path d="M5 17h14"></path></svg>',
        up: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 19V5"></path><path d="M5 12l7-7 7 7"></path></svg>',
        mode: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M4 5h16"></path><path d="M4 12h16"></path><path d="M4 19h16"></path><path d="M8 3v18"></path><path d="M16 3v18"></path></svg>',
        fit: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M9 4H4v5"></path><path d="M15 4h5v5"></path><path d="M9 20H4v-5"></path><path d="M15 20h5v-5"></path></svg>',
        gap: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6 5h12"></path><path d="M6 12h12"></path><path d="M6 19h12"></path><path d="M12 7v3"></path><path d="M12 14v3"></path></svg>',
        reader: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M7 4.5h6.75A3.25 3.25 0 0 1 17 7.75V19a2.5 2.5 0 0 0-2.5-2.5H7z"></path></svg>',
        theme: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="4.5"></circle><path d="M12 2.5v2.5"></path><path d="M12 19v2.5"></path><path d="M4.9 4.9l1.8 1.8"></path><path d="M17.3 17.3l1.8 1.8"></path><path d="M2.5 12H5"></path><path d="M19 12h2.5"></path><path d="M4.9 19.1l1.8-1.8"></path><path d="M17.3 6.7l1.8-1.8"></path></svg>'
    };

    let pageData = [];
    let imagePages = [];
    let totalPages = 0;
    let scrollTicking = false;
    let state = loadSettings();

    const wait = setInterval(() => {
        if (window.pageData && window.pageData.length) {
            clearInterval(wait);

            pageData = window.pageData;
            imagePages = extractImagePages(pageData);
            totalPages = imagePages.length;

            if (!totalPages) return;

            ensureStyle();
            installNativePageTracker();
            mountLauncher();

            const detected = detectNativePageNo();

            if (detected) {
                rememberNativePage(detected, 'initial-detect');
            }
        }
    }, 100);

    function extractImagePages(data) {
        return data
            .map((page, originalIndex) => ({ ...page, originalIndex }))
            .filter(page => page.type === 'image');
    }

    function loadSettings() {
        const fallback = {
            themeColor: CONFIG.defaultThemeColor,
            readingMode: CONFIG.defaultReadingMode,
            gap: CONFIG.defaultGap,
            zoom: CONFIG.defaultZoom,

            overlay: null,
            reader: null,
            ui: null,
            observer: null,

            currentPageNo: 1,
            currentHash: null,
            suppressPageTrackingUntil: 0,

            lastNativePageNo: null,
            lastNativePageAt: 0,
            lastNativePageReason: 'fallback',

            menuOpen: false,
            openPanelName: null,
            pointerInsideUI: false,
            menuCollapseTimer: 0,

            wheelLocked: false,
            wheelUnlockTimer: 0,

            oldBodyOverflow: '',
            oldHtmlOverflow: '',

            picker: null,
            nativeObserver: null
        };

        try {
            return Object.assign(fallback, JSON.parse(localStorage.getItem(STORAGE.settings) || '{}'));
        } catch {
            return fallback;
        }
    }

    function saveSettings() {
        localStorage.setItem(STORAGE.settings, JSON.stringify({
            themeColor: state.themeColor,
            readingMode: state.readingMode,
            gap: state.gap,
            zoom: state.zoom
        }));
    }

    function mountLauncher() {
        if (document.getElementById(IDS.launcher)) return;

        const button = document.createElement('button');
        button.id = IDS.launcher;
        button.type = 'button';
        button.innerHTML = ICONS.openBook;
        button.title = 'Open overlay reader';
        button.setAttribute('aria-label', 'Open overlay reader');

        button.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();
            openOverlayFromNative();
        });

        document.body.appendChild(button);
    }

    function openOverlayFromNative() {
        const current = detectNativePageNo() || getPageNoFromPossibleGlobals() || getBestNativePageNo() || 1;
        const safe = clampPage(current);

        console.log('[HNIR] Opening overlay from native page:', safe);

        rememberNativePage(safe, 'launcher-click');
        openOverlay(safe);
    }

    function openOverlay(startPageNo) {
        if (state.overlay?.isConnected) return;

        state.currentPageNo = clampPage(startPageNo || getBestNativePageNo());

        state.oldBodyOverflow = document.body.style.overflow;
        state.oldHtmlOverflow = document.documentElement.style.overflow;

        document.body.style.overflow = 'hidden';
        document.documentElement.style.overflow = 'hidden';

        const launcher = document.getElementById(IDS.launcher);
        if (launcher) launcher.style.display = 'none';

        const overlay = document.createElement('div');
        overlay.id = IDS.overlay;

        const reader = document.createElement('div');
        reader.id = IDS.reader;
        reader.className = 'hnir-reader';

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

        state.overlay = overlay;
        state.reader = reader;

        createProgressBar(overlay);
        createPageIndicator(overlay);
        mountOverlayUI(overlay);

        applyAllSettings();
        renderReader(state.currentPageNo);
        setupOverlayEvents();

        stabilizedJumpToPage(state.currentPageNo, 'auto');
    }

    function closeOverlay(syncNative = true) {
        if (!state.overlay) return;

        if (syncNative) {
            syncNativeReaderToPage(state.currentPageNo);
        }

        clearMenuCollapse();
        clearTimeout(state.wheelUnlockTimer);

        if (state.observer) {
            state.observer.disconnect();
            state.observer = null;
        }

        removeOverlayEvents();

        if (state.overlay.isConnected) {
            state.overlay.remove();
        }

        state.overlay = null;
        state.reader = null;
        state.ui = null;
        state.menuOpen = false;
        state.openPanelName = null;
        state.pointerInsideUI = false;
        state.wheelLocked = false;

        document.body.style.overflow = state.oldBodyOverflow || '';
        document.documentElement.style.overflow = state.oldHtmlOverflow || '';

        const launcher = document.getElementById(IDS.launcher);
        if (launcher) launcher.style.display = '';
    }

    function installNativePageTracker() {
        document.addEventListener('click', event => {
            const pageNo = inferPageNoFromClickedElement(event.target);

            if (pageNo) {
                rememberNativePage(pageNo, 'native-click');
            }

            setTimeout(() => {
                const detected = detectNativePageNo();
                if (detected) rememberNativePage(detected, 'native-click-after-80ms');
            }, 80);

            setTimeout(() => {
                const detected = detectNativePageNo();
                if (detected) rememberNativePage(detected, 'native-click-after-260ms');
            }, 260);
        }, true);

        const nativeRoot = document.querySelector('#pageChangeSnap, #reader_container, #reader_image, #nextLink') || document.body;

        if (nativeRoot && !state.nativeObserver) {
            state.nativeObserver = new MutationObserver(() => {
                const detected = detectNativePageNo();
                if (detected) rememberNativePage(detected, 'native-mutation');
            });

            state.nativeObserver.observe(nativeRoot, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['src', 'href', 'class', 'style']
            });
        }

        wrapNativeNavFunctions();
    }

    function wrapNativeNavFunctions() {
        const names = [
            'nextPage',
            'prevPage',
            'previousPage',
            'goPage',
            'goToPage',
            'setPage',
            'changePage',
            'loadPage',
            'openPage'
        ];

        names.forEach(name => {
            if (typeof window[name] !== 'function') return;
            if (window[name].__hnirWrapped) return;

            const original = window[name];

            window[name] = function (...args) {
                const before = getBestNativePageNo();

                if (/next/i.test(name)) {
                    rememberNativePage(before + 1, 'wrapped-nextPage-before');
                } else if (/prev|previous/i.test(name)) {
                    rememberNativePage(before - 1, 'wrapped-prevPage-before');
                } else if (args.length) {
                    const possible = parsePageNoFromText(args[0]);
                    if (possible) rememberNativePage(possible, `wrapped-${name}-arg`);
                }

                const result = original.apply(this, args);

                setTimeout(() => {
                    const detected = detectNativePageNo();
                    if (detected) rememberNativePage(detected, `wrapped-${name}-after`);
                }, 120);

                return result;
            };

            window[name].__hnirWrapped = true;
        });
    }

    function inferPageNoFromClickedElement(target) {
        const el = target?.closest?.('a, button, [onclick], [data-page], [data-index], [data-id], img, figure, li');
        if (!el) return null;

        const img = el.matches('img') ? el : el.querySelector?.('img');

        if (img) {
            const fromImg = findPageNoByImageSrc(img.currentSrc || img.src || img.getAttribute('src') || img.getAttribute('data-src'));
            if (fromImg) return fromImg;
        }

        for (const attr of ['data-page', 'data-page-no', 'data-index', 'data-number', 'data-id']) {
            const value = el.getAttribute?.(attr);
            const pageNo = parsePageNoFromText(value);
            if (pageNo) return pageNo;
        }

        const href = el.getAttribute?.('href');
        const fromHref = parsePageNoFromHrefOrHash(href);
        if (fromHref) return fromHref;

        const onclick = el.getAttribute?.('onclick');
        const fromOnclick = parsePageNoFromText(onclick);
        if (fromOnclick) return fromOnclick;

        if (/nextPage\s*\(/i.test(onclick || '') || /next/i.test(el.textContent || '')) {
            return clampPage((state.lastNativePageNo || detectNativePageNo() || 1) + 1);
        }

        if (/prevPage\s*\(/i.test(onclick || '') || /prev|previous/i.test(el.textContent || '')) {
            return clampPage((state.lastNativePageNo || detectNativePageNo() || 1) - 1);
        }

        const text = el.textContent?.trim();
        const fromText = parsePageNoFromText(text);

        if (fromText) return fromText;

        return null;
    }

    function getBestNativePageNo() {
        const detected = detectNativePageNo();

        if (detected) {
            rememberNativePage(detected, 'best-native-detected');
            return detected;
        }

        if (state.lastNativePageNo && state.lastNativePageNo >= 1 && state.lastNativePageNo <= totalPages) {
            return clampPage(state.lastNativePageNo);
        }

        return 1;
    }

    function rememberNativePage(pageNo, reason) {
        const safe = clampPage(pageNo);

        if (!safe) return;

        state.lastNativePageNo = safe;
        state.lastNativePageAt = Date.now();
        state.lastNativePageReason = reason || 'unknown';
    }

    function detectNativePageNo() {
        return (
            getPageNoFromPossibleGlobals() ||
            getPageNoFromHash() ||
            getPageNoFromNativeImage() ||
            getPageNoFromNativePagination() ||
            getPageNoFromNativeLinks() ||
            null
        );
    }

    function getPageNoFromPossibleGlobals() {
        if (typeof window.currentPage === 'number' || typeof window.currentPage === 'string') {
            const num = parsePageNoFromText(window.currentPage);

            if (num) return num;
        }

        const possibleNames = [
            'currentPage',
            'current_page',
            'readerPage',
            'reader_page',
            'page',
            'pageNum',
            'pageNumber',
            'currentImage',
            'currentIndex',
            'pageIndex'
        ];

        for (const name of possibleNames) {
            const value = window[name];

            if (typeof value === 'number' || typeof value === 'string') {
                const num = parsePageNoFromText(value);

                if (num) return num;
            }
        }

        return null;
    }

    function getPageNoFromHash() {
        const hash = location.hash.replace('#', '').trim();

        if (!hash) return null;
        if (hash === 'end') return totalPages;

        const labelIndex = imagePages.findIndex(page => page.url_label === hash);

        if (labelIndex >= 0) return labelIndex + 1;

        return parsePageNoFromText(hash);
    }

    function getPageNoFromNativeImage() {
        const selectors = [
            '#reader_image img',
            '#nextLink img',
            'figure#reader_image img',
            '#pageChangeSnap img',
            '.reader-image img'
        ];

        for (const selector of selectors) {
            const img = document.querySelector(selector);
            if (!img) continue;

            const candidates = [
                img.currentSrc,
                img.src,
                img.getAttribute('src'),
                img.getAttribute('data-src')
            ].filter(Boolean);

            for (const src of candidates) {
                const matched = findPageNoByImageSrc(src);
                if (matched) return matched;
            }
        }

        return null;
    }

    function getPageNoFromNativePagination() {
        const selectors = [
            '.reader-pagination-current',
            '.pagination-link.is-current',
            '.pagination-link.is-current.is-hidden-tablet',
            '#pageChangeSnap .is-current',
            '#pageChangeSnap .pagination-link.is-current',
            'nav.reader-pagination .is-current',
            '.reader-pagination-list .is-current'
        ];

        for (const selector of selectors) {
            const el = document.querySelector(selector);
            const num = parsePageNoFromText(el?.textContent);

            if (num) return num;
        }

        return null;
    }

    function getPageNoFromNativeLinks() {
        const nextLink = document.querySelector('#nextLink, .reader-pagination-next, .pagination-next');
        const prevLink = document.querySelector('.reader-pagination-prev, .pagination-previous');

        const nextPage = parsePageNoFromHrefOrHash(nextLink?.getAttribute?.('href'));

        if (nextPage && nextPage > 1) {
            return clampPage(nextPage - 1);
        }

        const prevPage = parsePageNoFromHrefOrHash(prevLink?.getAttribute?.('href'));

        if (prevPage) {
            return clampPage(prevPage + 1);
        }

        return null;
    }

    function parsePageNoFromHrefOrHash(value) {
        if (!value) return null;

        const raw = String(value).trim();
        const hash = raw.includes('#') ? raw.split('#').pop() : raw;

        if (hash) {
            const labelIndex = imagePages.findIndex(page => page.url_label === hash);

            if (labelIndex >= 0) {
                return labelIndex + 1;
            }
        }

        return parsePageNoFromText(raw);
    }

    function parsePageNoFromText(value) {
        if (value === null || value === undefined) return null;

        const text = String(value).trim();

        if (!text) return null;

        const exact = parseInt(text, 10);

        if (Number.isFinite(exact) && exact >= 1 && exact <= totalPages) {
            return exact;
        }

        const patterns = [
            /(?:page|p|index|goPage|goToPage|setPage|loadPage|openPage)[^\d]{0,10}(\d{1,5})/i,
            /\/(\d{1,5})(?:[/?#]|$)/,
            /#(\d{1,5})(?:$|[^\d])/,
            /(\d{1,5})\.(?:jpg|jpeg|png|webp|gif)(?:\?|#|$)/i
        ];

        for (const pattern of patterns) {
            const match = text.match(pattern);

            if (match) {
                const num = parseInt(match[1], 10);

                if (Number.isFinite(num) && num >= 1 && num <= totalPages) {
                    return num;
                }
            }
        }

        return null;
    }

    function findPageNoByImageSrc(src) {
        if (!src) return null;

        const normalizedSrc = normalizeUrlish(src);
        const filename = getFilename(src);

        for (let i = 0; i < imagePages.length; i++) {
            const page = imagePages[i];
            const pageImage = page.image || '';
            const normalizedPage = normalizeUrlish(pageImage);
            const pageFilename = getFilename(pageImage);

            if (
                normalizedSrc === normalizedPage ||
                normalizedSrc.endsWith(normalizedPage) ||
                normalizedPage.endsWith(normalizedSrc) ||
                filename && pageFilename && filename === pageFilename ||
                filename && normalizedPage.includes(filename)
            ) {
                return i + 1;
            }
        }

        const numeric = parsePageNoFromText(src);

        if (numeric) return numeric;

        return null;
    }

    function syncNativeReaderToPage(pageNo) {
        const safePageNo = clampPage(pageNo);
        const page = imagePages[safePageNo - 1];

        if (!page) return;

        state.currentPageNo = safePageNo;
        rememberNativePage(safePageNo, 'overlay-close-sync');

        if (page.url_label) {
            localStorage.setItem(STORAGE.progress, page.url_label);

            const url = new URL(location.href);
            url.hash = page.url_label;

            history.replaceState(null, '', url.toString());

            setTimeout(() => {
                location.reload();
            }, 80);

            return;
        }

        if (typeof window.setPage === 'function') {
            window.setPage(safePageNo);
        }

        setTimeout(() => {
            location.reload();
        }, 80);
    }

    function renderReader(targetPageNo = state.currentPageNo) {
        if (!state.reader) return;

        if (state.observer) {
            state.observer.disconnect();
            state.observer = null;
        }

        state.reader.innerHTML = '';
        state.reader.className = `hnir-reader hnir-mode-${state.readingMode}`;

        if (state.readingMode === 'manga') {
            renderMangaReader();
        } else {
            renderWebtoonReader();
        }

        applyGap();
        applyZoom();
        setupObserver();

        stabilizedJumpToPage(targetPageNo, 'auto');
    }

    function renderWebtoonReader() {
        imagePages.forEach((page, index) => {
            const pageNo = index + 1;

            const wrapper = document.createElement('div');
            wrapper.className = 'hnir-page';
            wrapper.dataset.pageNo = pageNo;
            wrapper.dataset.index = page.originalIndex;
            wrapper.dataset.observePage = pageNo;

            const img = createImage(page, pageNo);
            const errorBox = createErrorBox(img);

            wrapper.append(img, errorBox);
            state.reader.appendChild(wrapper);
        });
    }

    function renderMangaReader() {
        for (let i = 0; i < imagePages.length; i += 2) {
            const firstPageNo = i + 1;
            const secondPageNo = i + 2;

            const spread = document.createElement('div');
            spread.className = 'hnir-spread';
            spread.dataset.spreadFirst = firstPageNo;
            spread.dataset.observePage = firstPageNo;

            const leftSlot = createMangaSlot(secondPageNo);
            const rightSlot = createMangaSlot(firstPageNo);

            spread.append(leftSlot, rightSlot);
            state.reader.appendChild(spread);
        }
    }

    function createMangaSlot(pageNo) {
        const slot = document.createElement('div');
        slot.className = 'hnir-manga-slot';

        if (pageNo > totalPages) {
            slot.classList.add('is-empty');
            return slot;
        }

        const page = imagePages[pageNo - 1];
        const img = createImage(page, pageNo);
        const errorBox = createErrorBox(img);

        slot.dataset.pageNo = pageNo;
        slot.append(img, errorBox);

        return slot;
    }

    function createImage(page, pageNo) {
        const img = document.createElement('img');
        img.className = 'hnir-image';
        img.dataset.src = page.image;
        img.dataset.retryCount = '0';
        img.dataset.pageNo = pageNo;
        img.alt = `Page ${pageNo}`;
        img.decoding = 'async';
        img.loading = 'lazy';
        return img;
    }

    function createErrorBox(img) {
        const errorBox = document.createElement('div');
        errorBox.className = 'hnir-error';
        errorBox.innerHTML = `
            <div>Image failed to load.</div>
            <button type="button">Retry</button>
        `;

        errorBox.querySelector('button').addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            img.dataset.retryCount = '0';
            errorBox.classList.remove('visible');
            loadImage(img, true);
        });

        return errorBox;
    }

    function setupObserver() {
        if (!state.overlay || !state.reader) return;

        state.observer = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (!entry.isIntersecting) return;

                const target = entry.target;

                target.querySelectorAll('img[data-src]').forEach(img => {
                    loadImage(img);
                });

                const pageNo = parseInt(
                    target.dataset.pageNo ||
                    target.dataset.spreadFirst ||
                    target.dataset.observePage ||
                    state.currentPageNo,
                    10
                );

                maintainMemory(pageNo);
            });
        }, {
            root: state.overlay,
            rootMargin: CONFIG.rootMargin
        });

        state.reader.querySelectorAll('[data-observe-page]').forEach(el => {
            state.observer.observe(el);
        });
    }

    function setupOverlayEvents() {
        if (!state.overlay) return;

        window.__HNIR_KEY_HANDLER__ = handleKeydown;
        window.__HNIR_RESIZE_HANDLER__ = handleResize;
        state.overlay.__HNIR_SCROLL_HANDLER__ = handleOverlayScroll;
        state.overlay.__HNIR_WHEEL_HANDLER__ = handleOverlayWheel;

        window.addEventListener('keydown', window.__HNIR_KEY_HANDLER__);
        window.addEventListener('resize', window.__HNIR_RESIZE_HANDLER__, { passive: true });
        state.overlay.addEventListener('scroll', state.overlay.__HNIR_SCROLL_HANDLER__, { passive: true });
        state.overlay.addEventListener('wheel', state.overlay.__HNIR_WHEEL_HANDLER__, { passive: false });
    }

    function removeOverlayEvents() {
        if (window.__HNIR_KEY_HANDLER__) {
            window.removeEventListener('keydown', window.__HNIR_KEY_HANDLER__);
            window.__HNIR_KEY_HANDLER__ = null;
        }

        if (window.__HNIR_RESIZE_HANDLER__) {
            window.removeEventListener('resize', window.__HNIR_RESIZE_HANDLER__);
            window.__HNIR_RESIZE_HANDLER__ = null;
        }

        if (state.overlay?.__HNIR_SCROLL_HANDLER__) {
            state.overlay.removeEventListener('scroll', state.overlay.__HNIR_SCROLL_HANDLER__);
        }

        if (state.overlay?.__HNIR_WHEEL_HANDLER__) {
            state.overlay.removeEventListener('wheel', state.overlay.__HNIR_WHEEL_HANDLER__);
        }
    }

    function handleOverlayScroll() {
        if (scrollTicking) return;

        scrollTicking = true;

        requestAnimationFrame(() => {
            updateCurrentPage();
            updateProgressBar();
            scrollTicking = false;
        });
    }

    function handleOverlayWheel(event) {
        if (!state.overlay) return;
        if (state.zoom !== 0) return;

        event.preventDefault();

        if (state.wheelLocked) return;

        state.wheelLocked = true;

        clearTimeout(state.wheelUnlockTimer);
        state.wheelUnlockTimer = setTimeout(() => {
            state.wheelLocked = false;
        }, CONFIG.wheelLockMs);

        if (event.deltaY > 0) {
            goNextUnit();
        } else if (event.deltaY < 0) {
            goPreviousUnit();
        }
    }

    function handleKeydown(event) {
        if (!state.overlay) return;

        if (event.key === 'Escape') {
            event.preventDefault();
            closeOverlay(true);
            return;
        }

        const tag = document.activeElement?.tagName?.toLowerCase();

        if (tag === 'input' || tag === 'select' || tag === 'textarea') {
            return;
        }

        if (event.key.toLowerCase() === 't') {
            event.preventDefault();
            openPanel('theme');
            return;
        }

        if (event.key.toLowerCase() === 'f') {
            event.preventDefault();
            updateZoom(state.zoom === 0 ? 100 : 0, true);
            return;
        }

        if (event.key === '+' || event.key === '=') {
            event.preventDefault();
            updateZoom(state.zoom + 5, true);
            return;
        }

        if (event.key === '-' || event.key === '_') {
            event.preventDefault();
            updateZoom(state.zoom - 5, true);
            return;
        }

        if (event.key === '0') {
            event.preventDefault();
            updateZoom(0, true);
            return;
        }

        if (state.zoom !== 0) return;

        if (state.readingMode === 'manga') {
            if (event.key === 'ArrowLeft' || event.key === 'PageDown' || event.key === ' ') {
                event.preventDefault();
                goNextUnit();
            }

            if (event.key === 'ArrowRight' || event.key === 'PageUp') {
                event.preventDefault();
                goPreviousUnit();
            }

            return;
        }

        if (event.key === 'ArrowDown' || event.key === 'PageDown' || event.key === ' ') {
            event.preventDefault();
            goNextUnit();
        }

        if (event.key === 'ArrowUp' || event.key === 'PageUp') {
            event.preventDefault();
            goPreviousUnit();
        }
    }

    function handleResize() {
        applyZoom();
        updateProgressBar();
    }

    function goNextUnit() {
        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(state.currentPageNo);
            jumpToPage(Math.min(totalPages, spreadFirst + 2), 'smooth');
        } else {
            jumpToPage(Math.min(totalPages, state.currentPageNo + 1), 'smooth');
        }
    }

    function goPreviousUnit() {
        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(state.currentPageNo);
            jumpToPage(Math.max(1, spreadFirst - 2), 'smooth');
        } else {
            jumpToPage(Math.max(1, state.currentPageNo - 1), 'smooth');
        }
    }

    function getSpreadFirstFromPage(pageNo) {
        const safe = clampPage(pageNo);
        return safe % 2 === 0 ? safe - 1 : safe;
    }

    function preloadImagesAroundPage(pageNo) {
        if (!state.reader) return;

        const safePageNo = clampPage(pageNo);
        const start = Math.max(1, safePageNo - 2);
        const end = Math.min(totalPages, safePageNo + 2);

        state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
            const imgPageNo = parseInt(img.dataset.pageNo, 10);

            if (imgPageNo >= start && imgPageNo <= end) {
                loadImage(img);
            }
        });
    }

    function stabilizedJumpToPage(pageNo, behavior = 'auto') {
        const safePageNo = clampPage(pageNo);

        state.suppressPageTrackingUntil = Date.now() + 2600;

        preloadImagesAroundPage(safePageNo);
        maintainMemory(safePageNo);

        requestAnimationFrame(() => {
            forceJumpToPage(safePageNo, behavior);

            requestAnimationFrame(() => {
                forceJumpToPage(safePageNo, 'auto');
            });
        });

        setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 120);
        setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 350);
        setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 800);
        setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 1400);

        setTimeout(() => {
            updatePageIndicator();
            updatePageInput();
            updateProgressBar();
            maintainMemory(safePageNo);
        }, 1700);
    }

    function forceJumpToPage(pageNo, behavior = 'auto') {
        if (!state.reader || !state.overlay) return;

        const safePageNo = clampPage(pageNo);
        let target;

        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(safePageNo);
            target = state.reader.querySelector(`.hnir-spread[data-spread-first="${spreadFirst}"]`);
            state.currentPageNo = Math.min(totalPages, spreadFirst + 1);
        } else {
            target = state.reader.querySelector(`.hnir-page[data-page-no="${safePageNo}"]`);
            state.currentPageNo = safePageNo;
        }

        if (!target) return;

        state.suppressPageTrackingUntil = Math.max(
            state.suppressPageTrackingUntil || 0,
            Date.now() + 900
        );

        const overlayRect = state.overlay.getBoundingClientRect();
        const targetRect = target.getBoundingClientRect();
        const targetTop = state.overlay.scrollTop + targetRect.top - overlayRect.top;

        state.overlay.scrollTo({
            top: Math.max(0, targetTop),
            behavior
        });

        maintainMemory(state.currentPageNo);
        updatePageIndicator();
        updatePageInput();
        saveProgress();
    }

    function jumpToPage(pageNo, behavior = 'smooth') {
        forceJumpToPage(pageNo, behavior);
    }

    function updateCurrentPage() {
        if (!state.overlay || !state.reader) return;

        if (state.suppressPageTrackingUntil && Date.now() < state.suppressPageTrackingUntil) {
            return;
        }

        const selector = state.readingMode === 'manga' ? '.hnir-spread' : '.hnir-page';
        const elements = state.reader.querySelectorAll(selector);
        const viewportCenter = window.innerHeight / 2;

        let closest = null;
        let closestDistance = Infinity;

        elements.forEach(el => {
            const rect = el.getBoundingClientRect();
            const center = rect.top + rect.height / 2;
            const distance = Math.abs(viewportCenter - center);

            if (distance < closestDistance) {
                closestDistance = distance;
                closest = el;
            }
        });

        if (!closest) return;

        if (state.readingMode === 'manga') {
            const spreadFirst = parseInt(closest.dataset.spreadFirst, 10);
            state.currentPageNo = Math.min(totalPages, spreadFirst + 1);
        } else {
            state.currentPageNo = parseInt(closest.dataset.pageNo, 10);
        }

        updatePageIndicator();
        updatePageInput();
        saveProgress();
        maintainMemory(state.currentPageNo);
    }

    function saveProgress() {
        const page = imagePages[clampPage(state.currentPageNo) - 1];

        if (page?.url_label) {
            state.currentHash = page.url_label;
            localStorage.setItem(STORAGE.progress, page.url_label);
        }
    }

    function maintainMemory(currentPage) {
        if (!state.reader) return;

        const safeCurrent = clampPage(currentPage);

        state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
            const pageNo = parseInt(img.dataset.pageNo, 10);
            const distance = Math.abs(pageNo - safeCurrent);

            if (distance <= CONFIG.loadBuffer) {
                loadImage(img);
            }

            if (distance > CONFIG.unloadBuffer) {
                unloadImage(img);
            }
        });
    }

    function loadImage(img, force = false) {
        if (!img || (!force && img.src)) return;

        const src = img.dataset.src;
        if (!src) return;

        const errorBox = img.parentElement?.querySelector('.hnir-error');

        if (force) {
            img.classList.remove('loaded');
            img.removeAttribute('src');
        }

        errorBox?.classList.remove('visible');

        img.onload = () => {
            img.classList.add('loaded');
            img.dataset.retryCount = '0';

            if (img.naturalWidth) {
                img.style.setProperty('--hnir-natural-width', `${img.naturalWidth}px`);
            }

            errorBox?.classList.remove('visible');
        };

        img.onerror = () => {
            img.classList.remove('loaded');

            let retryCount = parseInt(img.dataset.retryCount || '0', 10);
            retryCount += 1;
            img.dataset.retryCount = String(retryCount);

            img.removeAttribute('src');

            if (retryCount <= CONFIG.maxRetries) {
                setTimeout(() => {
                    img.src = src;
                }, 1000 * retryCount);
            } else {
                errorBox?.classList.add('visible');
            }
        };

        img.src = src;
    }

    function unloadImage(img) {
        if (!img || !img.src) return;

        img.classList.remove('loaded');

        setTimeout(() => {
            img.removeAttribute('src');
        }, 160);
    }

    function mountOverlayUI(overlay) {
        const root = document.createElement('div');
        root.id = IDS.uiRoot;

        const topButtonWrap = document.createElement('div');
        topButtonWrap.className = 'hnir-top-button';

        const topButton = document.createElement('button');
        topButton.type = 'button';
        topButton.className = 'hnir-btn';
        topButton.innerHTML = ICONS.up;
        topButton.title = 'Click: top. Hold: bottom.';
        topButton.setAttribute('aria-label', 'Click to scroll top. Hold to scroll bottom.');

        topButtonWrap.appendChild(topButton);

        const actionStack = document.createElement('div');
        actionStack.className = 'hnir-action-stack';

        const modeItem = createStackItem('mode', ICONS.mode, 'Reading mode');
        const fitItem = createStackItem('fit', ICONS.fit, 'Fit and zoom');
        const gapItem = createStackItem('gap', ICONS.gap, 'Page gap');
        const pageItem = createStackItem('page', ICONS.reader, 'Jump to page');
        const themeItem = createStackItem('theme', ICONS.theme, 'Theme color');
        const closeItem = createDirectStackItem('close', ICONS.closedBook, 'Close reader');

        actionStack.append(
            modeItem.item,
            fitItem.item,
            gapItem.item,
            pageItem.item,
            themeItem.item,
            closeItem.item
        );

        const menuButtonWrap = document.createElement('div');
        menuButtonWrap.className = 'hnir-menu-button';

        const menuButton = document.createElement('button');
        menuButton.type = 'button';
        menuButton.className = 'hnir-btn';
        menuButton.innerHTML = ICONS.menu;
        menuButton.title = 'Menu';
        menuButton.setAttribute('aria-label', 'Menu');

        menuButtonWrap.appendChild(menuButton);

        root.append(topButtonWrap, actionStack, menuButtonWrap);
        overlay.appendChild(root);

        const modeToggles = createToggleGroup([
            {
                label: 'Webtoon',
                active: state.readingMode === 'webtoon',
                onSelect() {
                    updateReadingMode('webtoon');
                    armMenuCollapse();
                }
            },
            {
                label: 'Manga',
                active: state.readingMode === 'manga',
                onSelect() {
                    updateReadingMode('manga');
                    armMenuCollapse();
                }
            }
        ]);

        modeItem.panel.appendChild(modeToggles.root);

        fitItem.panel.classList.add('hnir-panel-vertical');

        const fitToggles = createToggleGroup([
            {
                label: 'Fit',
                active: state.zoom === 0,
                onSelect() {
                    updateZoom(0, true);
                    armMenuCollapse();
                }
            },
            {
                label: 'Fullscreen',
                active: state.zoom === 100,
                onSelect() {
                    updateZoom(100, true);
                    armMenuCollapse();
                }
            }
        ]);

        const zoomWrap = document.createElement('div');
        zoomWrap.className = 'hnir-slider-wrap';

        const zoomTop = document.createElement('div');
        zoomTop.className = 'hnir-slider-top';

        const zoomLabel = document.createElement('span');
        zoomLabel.textContent = 'Zoom';

        const zoomValue = document.createElement('span');
        zoomValue.className = 'hnir-slider-value';
        zoomValue.textContent = `${state.zoom}%`;

        zoomTop.append(zoomLabel, zoomValue);

        const zoomRow = document.createElement('div');
        zoomRow.className = 'hnir-slider-row';

        const zoomSlider = document.createElement('input');
        zoomSlider.type = 'range';
        zoomSlider.min = '0';
        zoomSlider.max = '100';
        zoomSlider.step = '1';
        zoomSlider.value = String(state.zoom);

        const zoomReset = document.createElement('button');
        zoomReset.type = 'button';
        zoomReset.textContent = 'Fit';

        zoomRow.append(zoomSlider, zoomReset);
        zoomWrap.append(zoomTop, zoomRow);

        fitItem.panel.append(fitToggles.root, zoomWrap);

        const gapToggles = createToggleGroup([
            {
                label: '0px',
                active: state.gap === 0,
                onSelect() {
                    updateGap(0);
                    armMenuCollapse();
                }
            },
            {
                label: '5px',
                active: state.gap === 5,
                onSelect() {
                    updateGap(5);
                    armMenuCollapse();
                }
            },
            {
                label: '10px',
                active: state.gap === 10,
                onSelect() {
                    updateGap(10);
                    armMenuCollapse();
                }
            },
            {
                label: '15px',
                active: state.gap === 15,
                onSelect() {
                    updateGap(15);
                    armMenuCollapse();
                }
            }
        ]);

        gapItem.panel.appendChild(gapToggles.root);

        const pagePanel = document.createElement('div');
        pagePanel.className = 'hnir-jump';

        const pageInput = document.createElement('input');
        pageInput.type = 'number';
        pageInput.min = '1';
        pageInput.max = String(totalPages);
        pageInput.placeholder = `1-${totalPages}`;

        const pageButton = document.createElement('button');
        pageButton.type = 'button';
        pageButton.textContent = 'Go';

        pagePanel.append(pageInput, pageButton);
        pageItem.panel.appendChild(pagePanel);

        themeItem.panel.classList.add('hnir-panel-vertical');

        const colorPicker = createCustomColorPicker();
        themeItem.panel.appendChild(colorPicker.root);

        root.addEventListener('pointerenter', () => {
            state.pointerInsideUI = true;
            clearMenuCollapse();
        });

        root.addEventListener('pointerleave', () => {
            state.pointerInsideUI = false;
            armMenuCollapse();
        });

        menuButton.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            if (state.menuOpen) {
                closeMenu();
            } else {
                openMenu();
            }
        });

        closeItem.button.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();
            closeOverlay(true);
        });

        wirePanelToggle(modeItem, 'mode');
        wirePanelToggle(fitItem, 'fit');
        wirePanelToggle(gapItem, 'gap');
        wirePanelToggle(pageItem, 'page', { noAutoCollapse: true });
        wirePanelToggle(themeItem, 'theme');

        pageButton.addEventListener('click', () => {
            jumpToPage(pageInput.value, 'smooth');
        });

        pageInput.addEventListener('keydown', event => {
            if (event.key === 'Enter') {
                jumpToPage(pageInput.value, 'smooth');
            }
        });

        zoomSlider.addEventListener('input', () => {
            updateZoom(parseInt(zoomSlider.value, 10), false);
        });

        zoomSlider.addEventListener('change', () => {
            saveSettings();
            armMenuCollapse();
        });

        zoomReset.addEventListener('click', () => {
            updateZoom(0, true);
            armMenuCollapse();
        });

        setupTopButton(topButton);

        state.ui = {
            root,
            menuButton,
            topButton,
            items: {
                mode: modeItem,
                fit: fitItem,
                gap: gapItem,
                page: pageItem,
                theme: themeItem,
                close: closeItem
            },
            modeButtons: modeToggles.buttons,
            fitButtons: fitToggles.buttons,
            gapButtons: gapToggles.buttons,
            pageInput,
            zoomSlider,
            zoomValue,
            color: colorPicker
        };

        updatePageInput();
        syncUI();
    }

    function createCustomColorPicker() {
        const root = document.createElement('div');
        root.className = 'hnir-color-picker';

        const top = document.createElement('div');
        top.className = 'hnir-color-picker-top';

        const preview = document.createElement('div');
        preview.className = 'hnir-color-preview';

        const hexInput = document.createElement('input');
        hexInput.className = 'hnir-color-hex';
        hexInput.type = 'text';
        hexInput.value = state.themeColor;

        top.append(preview, hexInput);

        const square = document.createElement('div');
        square.className = 'hnir-color-square';

        const knob = document.createElement('div');
        knob.className = 'hnir-color-knob';
        square.appendChild(knob);

        const hueRow = document.createElement('div');
        hueRow.className = 'hnir-hue-row';

        const hueSlider = document.createElement('input');
        hueSlider.className = 'hnir-hue-slider';
        hueSlider.type = 'range';
        hueSlider.min = '0';
        hueSlider.max = '360';
        hueSlider.step = '1';

        const resetButton = document.createElement('button');
        resetButton.type = 'button';
        resetButton.textContent = 'Reset';

        hueRow.append(hueSlider, resetButton);

        root.append(top, square, hueRow);

        state.picker = hexToHsv(state.themeColor);

        const updateSquareFromPointer = event => {
            const rect = square.getBoundingClientRect();
            const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
            const y = Math.max(0, Math.min(rect.height, event.clientY - rect.top));

            state.picker.s = x / rect.width;
state.picker.v = 1 - (y / rect.height);

            updateThemeColorFromPicker(false);
        };

        square.addEventListener('pointerdown', event => {
            event.preventDefault();
            square.setPointerCapture(event.pointerId);
            updateSquareFromPointer(event);
        });

        square.addEventListener('pointermove', event => {
            if (event.buttons !== 1) return;
            updateSquareFromPointer(event);
        });

        square.addEventListener('pointerup', () => {
            saveSettings();
        });

        hueSlider.addEventListener('input', () => {
            state.picker.h = parseInt(hueSlider.value, 10);
updateThemeColorFromPicker(false);
        });

        hueSlider.addEventListener('change', () => {
            saveSettings();
            armMenuCollapse();
        });

        hexInput.addEventListener('change', () => {
            const fixed = normalizeHex(hexInput.value);

            if (!fixed) {
                hexInput.value = state.themeColor;
                return;
            }

            state.themeColor = fixed;
            state.picker = hexToHsv(fixed);
            applyThemeColor();
            updateColorPickerUI();
            saveSettings();
        });

        resetButton.addEventListener('click', () => {
            state.themeColor = CONFIG.defaultThemeColor;
            state.picker = hexToHsv(state.themeColor);
            applyThemeColor();
            updateColorPickerUI();
            saveSettings();
            armMenuCollapse();
        });

        return {
            root,
            preview,
            hexInput,
            square,
            knob,
            hueSlider
        };
    }

    function updateThemeColorFromPicker(shouldSave) {
        state.themeColor = hsvToHex(state.picker.h, state.picker.s, state.picker.v);
        applyThemeColor();
        updateColorPickerUI();

        if (shouldSave) {
            saveSettings();
        }
    }

    function updateColorPickerUI() {
    if (!state.ui?.color || !state.picker) return;

    const { preview, hexInput, square, knob, hueSlider } = state.ui.color;

    preview.style.backgroundColor = state.themeColor;
    hexInput.value = state.themeColor;

    hueSlider.value = String(Math.round(state.picker.h));
    square.style.setProperty('--hnir-picker-hue', String(Math.round(state.picker.h)));

    knob.style.left = `${state.picker.s * 100}%`;
    knob.style.top = `${(1 - state.picker.v) * 100}%`;
}

    function setupTopButton(topButton) {
        let navPressTimer = 0;
        let navLongPressFired = false;

        const clearNavTimer = () => {
            if (navPressTimer) {
                clearTimeout(navPressTimer);
                navPressTimer = 0;
            }
        };

        topButton.addEventListener('pointerdown', event => {
            event.preventDefault();
            event.stopPropagation();

            navLongPressFired = false;
            clearNavTimer();

            navPressTimer = window.setTimeout(() => {
                navLongPressFired = true;

                state.overlay.scrollTo({
                    top: state.overlay.scrollHeight,
                    behavior: 'smooth'
                });

                navPressTimer = 0;
            }, CONFIG.longPressMs);
        });

        const releaseNav = event => {
            event.preventDefault();
            event.stopPropagation();

            if (navPressTimer) {
                clearNavTimer();

                if (!navLongPressFired) {
                    state.overlay.scrollTo({
                        top: 0,
                        behavior: 'smooth'
                    });
                }
            }
        };

        topButton.addEventListener('pointerup', releaseNav);
        topButton.addEventListener('pointerleave', clearNavTimer);
        topButton.addEventListener('pointercancel', clearNavTimer);
    }

    function createStackItem(name, icon, label) {
        const item = document.createElement('div');
        item.className = 'hnir-stack-item';
        item.dataset.item = name;

        const panel = document.createElement('div');
        panel.className = 'hnir-panel';

        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'hnir-btn';
        button.innerHTML = icon;
        button.setAttribute('aria-label', label);
        button.title = label;

        item.append(panel, button);

        return { item, panel, button };
    }

    function createDirectStackItem(name, icon, label) {
        const item = document.createElement('div');
        item.className = 'hnir-stack-item';
        item.dataset.item = name;

        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'hnir-btn';
        button.innerHTML = icon;
        button.setAttribute('aria-label', label);
        button.title = label;

        item.appendChild(button);

        return { item, button };
    }

    function createToggleGroup(items) {
        const root = document.createElement('div');
        root.className = 'hnir-segmented';

        const buttons = items.map(item => {
            const button = document.createElement('button');
            button.type = 'button';
            button.className = `hnir-chip${item.active ? ' is-active' : ''}`;
            button.textContent = item.label;
            button.addEventListener('click', item.onSelect);
            root.appendChild(button);
            return button;
        });

        return { root, buttons };
    }

    function wirePanelToggle(entry, name, options = {}) {
        entry.button.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            const isOpen = entry.item.classList.contains('is-open');

            if (isOpen) {
                closePanels();

                if (!options.noAutoCollapse) {
                    armMenuCollapse();
                }

                return;
            }

            openPanel(name, options);
        });
    }

    function openMenu() {
        if (!state.ui) return;

        state.menuOpen = true;
        state.ui.root.classList.add('is-menu-open');

        armMenuCollapse();
    }

    function closeMenu() {
        if (!state.ui) return;

        clearMenuCollapse();

        state.menuOpen = false;
        state.openPanelName = null;

        state.ui.root.classList.remove('is-menu-open');
        closePanels();
    }

    function openPanel(name, options = {}) {
        if (!state.ui) return;

        state.menuOpen = true;
        state.openPanelName = name;
        state.ui.root.classList.add('is-menu-open');

        Object.entries(state.ui.items).forEach(([key, item]) => {
            item.item.classList.toggle('is-open', key === name);
        });

        if (name === 'theme') {
            updateColorPickerUI();
        }

        if (options.noAutoCollapse) {
            clearMenuCollapse();
            return;
        }

        armMenuCollapse();
    }

    function closePanels() {
        if (!state.ui) return;

        Object.values(state.ui.items).forEach(item => {
            item.item.classList.remove('is-open');
        });

        state.openPanelName = null;
    }

    function clearMenuCollapse() {
        clearTimeout(state.menuCollapseTimer);
        state.menuCollapseTimer = 0;
    }

    function armMenuCollapse() {
        clearMenuCollapse();

        if (!state.menuOpen) return;
        if (state.pointerInsideUI) return;
        if (state.openPanelName === 'page') return;

        state.menuCollapseTimer = window.setTimeout(() => {
            closeMenu();
        }, CONFIG.menuCollapseMs);
    }

    function updateReadingMode(mode) {
        if (state.readingMode === mode) return;

        const current = state.currentPageNo;

        state.readingMode = mode;
        saveSettings();
        renderReader(current);
        syncUI();
    }

    function updateGap(gap) {
        state.gap = gap;
        saveSettings();
        applyGap();
        syncUI();
    }

    function updateZoom(zoom, shouldSave) {
        state.zoom = Math.max(0, Math.min(100, zoom));

        applyZoom();
        syncUI();

        if (shouldSave) {
            saveSettings();
        }
    }

    function applyAllSettings() {
        applyThemeColor();
        applyGap();
        applyZoom();
        syncUI();
    }

    function applyThemeColor() {
        document.documentElement.style.setProperty('--hnir-theme-bg', state.themeColor);
    }

    function applyGap() {
        if (!state.reader) return;
        state.reader.style.gap = `${state.gap}px`;
        document.documentElement.style.setProperty('--hnir-reader-gap', `${state.gap}px`);
    }

    function applyZoom() {
        const viewportBase = Math.max(240, window.innerWidth - 32);
        const minFactor = 0.55;
        const factor = minFactor + (state.zoom / 100) * (1 - minFactor);

        const zoomWidth = Math.round(viewportBase * factor);
        const mangaPageWidth = Math.max(160, Math.floor((zoomWidth - state.gap - 24) / 2));

        document.documentElement.style.setProperty('--hnir-zoom-width', `${zoomWidth}px`);
        document.documentElement.style.setProperty('--hnir-manga-page-width', `${mangaPageWidth}px`);

        if (state.overlay) {
            state.overlay.classList.toggle('hnir-zoom-over', state.zoom > 0);
            state.overlay.classList.toggle('hnir-fit-mode', state.zoom === 0);
        }
    }

    function syncUI() {
        if (!state.ui) return;

        state.ui.modeButtons[0].classList.toggle('is-active', state.readingMode === 'webtoon');
        state.ui.modeButtons[1].classList.toggle('is-active', state.readingMode === 'manga');

        state.ui.fitButtons[0].classList.toggle('is-active', state.zoom === 0);
        state.ui.fitButtons[1].classList.toggle('is-active', state.zoom === 100);

        state.ui.gapButtons[0].classList.toggle('is-active', state.gap === 0);
        state.ui.gapButtons[1].classList.toggle('is-active', state.gap === 5);
        state.ui.gapButtons[2].classList.toggle('is-active', state.gap === 10);
        state.ui.gapButtons[3].classList.toggle('is-active', state.gap === 15);

        state.ui.zoomSlider.value = String(state.zoom);
        state.ui.zoomValue.textContent = `${state.zoom}%`;

        updateColorPickerUI();
        updatePageInput();
    }

    function createProgressBar(parent) {
        const bar = document.createElement('div');
        bar.id = IDS.progressBar;
        parent.appendChild(bar);
    }

    function createPageIndicator(parent) {
        const indicator = document.createElement('div');
        indicator.id = IDS.pageIndicator;
        indicator.textContent = `Page 1 / ${totalPages}`;
        parent.appendChild(indicator);
    }

    function updateProgressBar() {
        const bar = document.getElementById(IDS.progressBar);
        if (!bar || !state.overlay) return;

        const maxScroll = state.overlay.scrollHeight - state.overlay.clientHeight;
        const progress = maxScroll <= 0 ? 0 : (state.overlay.scrollTop / maxScroll) * 100;

        bar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
    }

    function updatePageIndicator() {
        const indicator = document.getElementById(IDS.pageIndicator);
        if (!indicator) return;

        if (state.readingMode === 'manga') {
            const first = getSpreadFirstFromPage(state.currentPageNo);
            const second = Math.min(totalPages, first + 1);
            indicator.textContent = `Pages ${first}${second !== first ? '–' + second : ''} / ${totalPages}`;
            return;
        }

        indicator.textContent = `Page ${state.currentPageNo} / ${totalPages}`;
    }

    function updatePageInput() {
        if (!state.ui?.pageInput) return;

        if (document.activeElement !== state.ui.pageInput) {
            state.ui.pageInput.value = String(state.currentPageNo);
        }
    }

    function ensureStyle() {
        if (document.getElementById(IDS.style)) return;

        const style = document.createElement('style');
        style.id = IDS.style;

        style.textContent = `
            :root {
                --hnir-theme-bg: ${state.themeColor};
                --hnir-ui-surface: rgba(245, 241, 232, 0.96);
                --hnir-ui-stroke: rgba(0, 0, 0, 0.08);
                --hnir-ui-text: #2d3137;
                --hnir-ui-track: rgba(0, 0, 0, 0.12);
                --hnir-ui-fill: #303843;
                --hnir-reader-gap: 5px;
                --hnir-zoom-width: calc(100vw - 32px);
                --hnir-manga-page-width: calc((100vw - 48px) / 2);
            }

            #${IDS.launcher} {
                position: fixed;
                right: 20px;
                bottom: 20px;
                z-index: 2147483646;
                width: 50px;
                height: 50px;
                border: 1px solid var(--hnir-ui-stroke);
                border-radius: 50%;
                background: var(--hnir-ui-surface);
                color: var(--hnir-ui-text);
                display: inline-flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition: transform 150ms ease, background-color 150ms ease, color 150ms ease;
            }

            #${IDS.launcher}:hover {
                transform: scale(1.05);
            }

            #${IDS.launcher} svg,
            .hnir-btn svg {
                display: block;
                fill: none;
                stroke: currentColor;
                stroke-width: 2.2;
                stroke-linecap: round;
                stroke-linejoin: round;
                flex: none;
            }

            #${IDS.overlay} {
                position: fixed;
                inset: 0;
                z-index: 2147483647;
                overflow-y: auto;
                overflow-x: hidden;
                background: var(--hnir-theme-bg);
                color: white;
                transition: background-color 180ms ease;
                overscroll-behavior: contain;
                scroll-behavior: smooth;
            }

            .hnir-reader {
                min-height: 100vh;
                display: flex;
                flex-direction: column;
                align-items: center;
                gap: var(--hnir-reader-gap);
                padding: 14px 0 120px;
                box-sizing: border-box;
            }

            .hnir-page {
                width: min(100%, ${CONFIG.fullMaxWidth}px);
                min-height: 300px;
                display: flex;
                justify-content: center;
                align-items: center;
                position: relative;
                scroll-margin-top: 10px;
                scroll-margin-bottom: 10px;
            }

            #${IDS.overlay}.hnir-fit-mode .hnir-mode-webtoon .hnir-page {
                min-height: calc(100vh - 32px);
            }

            .hnir-image {
                display: block;
                width: auto;
                height: auto;
                max-width: min(calc(100vw - 32px), ${CONFIG.fullMaxWidth}px);
                max-height: calc(100vh - 32px);
                border-radius: 10px;
                background: rgba(0, 0, 0, 0.07);
                box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
                object-fit: contain;
                image-rendering: auto;
                opacity: 0;
                transition:
                    opacity 220ms ease,
                    width 180ms ease,
                    max-width 180ms ease,
                    max-height 180ms ease,
                    border-radius 180ms ease,
                    box-shadow 180ms ease;
            }

            .hnir-image.loaded {
                opacity: 1;
            }

            #${IDS.overlay}.hnir-zoom-over .hnir-mode-webtoon .hnir-image {
                width: min(var(--hnir-zoom-width), ${CONFIG.fullMaxWidth}px);
                max-width: none;
                max-height: none;
                object-fit: initial;
            }

            .hnir-mode-manga {
                justify-content: flex-start;
            }

            .hnir-spread {
                min-height: calc(100vh - 32px);
                width: 100%;
                display: flex;
                flex-direction: row;
                justify-content: center;
                align-items: center;
                gap: var(--hnir-reader-gap);
                position: relative;
                scroll-margin-top: 12px;
                scroll-margin-bottom: 12px;
                box-sizing: border-box;
            }

            .hnir-manga-slot {
                position: relative;
                display: flex;
                justify-content: center;
                align-items: center;
                min-width: 0;
            }

            .hnir-manga-slot.is-empty {
                width: min(calc((100vw - 48px) / 2), 820px);
                min-height: 60vh;
            }

            .hnir-mode-manga .hnir-image {
                max-width: min(calc((100vw - 48px) / 2), 900px);
                max-height: calc(100vh - 48px);
            }

            #${IDS.overlay}.hnir-zoom-over .hnir-mode-manga .hnir-image {
                width: var(--hnir-manga-page-width);
                max-width: none;
                max-height: none;
                object-fit: initial;
            }

            .hnir-error {
                display: none;
                position: absolute;
                inset: 0;
                min-height: 220px;
                align-items: center;
                justify-content: center;
                flex-direction: column;
                gap: 12px;
                background: rgba(30, 30, 30, 0.82);
                color: white;
                font-family: "Segoe UI", Arial, sans-serif;
                font-size: 14px;
                text-align: center;
                border-radius: 10px;
            }

            .hnir-error.visible {
                display: flex;
            }

            .hnir-error button,
            .hnir-slider-row button,
            .hnir-jump button,
            .hnir-color-picker button {
                min-width: 58px;
                height: 32px;
                padding: 0 12px;
                border: 0;
                border-radius: 999px;
                background: #303843;
                color: #f7f8fa;
                cursor: pointer;
                font-size: 13px;
                font-weight: 700;
            }

            #${IDS.uiRoot} {
                position: fixed;
                right: 20px;
                bottom: 20px;
                z-index: 2147483647;
                display: flex;
                flex-direction: column;
                align-items: flex-end;
                gap: 12px;
                pointer-events: none;
                font-family: "Segoe UI", sans-serif;
                user-select: none;
            }

            .hnir-top-button,
            .hnir-menu-button {
                pointer-events: auto;
            }

            .hnir-action-stack {
                display: flex;
                flex-direction: column;
                align-items: flex-end;
                gap: 12px;
                max-height: 0;
                opacity: 0;
                overflow: visible;
                pointer-events: none;
                transition:
                    max-height 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
                    opacity 180ms ease;
            }

            #${IDS.uiRoot}.is-menu-open .hnir-action-stack {
                max-height: 520px;
                opacity: 1;
                pointer-events: auto;
            }

            .hnir-stack-item {
                position: relative;
                display: flex;
                align-items: center;
                justify-content: flex-end;
                min-height: 48px;
                pointer-events: none;
                opacity: 0;
                transform: translate3d(0, 14px, 0) scale(0.96);
                transition:
                    opacity 180ms ease,
                    transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
            }

            #${IDS.uiRoot}.is-menu-open .hnir-stack-item {
                opacity: 1;
                transform: translate3d(0, 0, 0) scale(1);
                pointer-events: auto;
            }

            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(1) { transition-delay: 20ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(2) { transition-delay: 45ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(3) { transition-delay: 70ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(4) { transition-delay: 95ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(5) { transition-delay: 120ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(6) { transition-delay: 145ms; }

            .hnir-panel {
                position: absolute;
                right: 56px;
                top: 50%;
                transform: translate3d(12px, -50%, 0);
                display: flex;
                align-items: center;
                gap: 10px;
                height: 46px;
                padding: 0;
                max-width: 0;
                overflow: hidden;
                white-space: nowrap;
                opacity: 0;
                pointer-events: none;
                border: 1px solid var(--hnir-ui-stroke);
                border-radius: 999px;
                background: var(--hnir-ui-surface);
                backdrop-filter: blur(14px);
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition:
                    max-width 220ms ease,
                    opacity 180ms ease,
                    transform 220ms ease,
                    padding 180ms ease;
            }

            .hnir-panel.hnir-panel-vertical {
                height: auto;
                min-height: 46px;
                flex-direction: column;
                align-items: stretch;
                justify-content: center;
                border-radius: 24px;
                white-space: normal;
            }

            .hnir-stack-item.is-open .hnir-panel {
                max-width: 440px;
                padding: 0 12px 0 16px;
                opacity: 1;
                transform: translate3d(0, -50%, 0);
                pointer-events: auto;
            }

            .hnir-stack-item.is-open .hnir-panel.hnir-panel-vertical {
                padding: 12px 14px;
                min-width: 270px;
            }

            .hnir-btn {
                width: 46px;
                height: 46px;
                border: 1px solid var(--hnir-ui-stroke);
                border-radius: 50%;
                background: var(--hnir-ui-surface);
                color: var(--hnir-ui-text);
                display: inline-flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition:
                    transform 150ms ease,
                    background-color 150ms ease,
                    color 150ms ease;
            }

            .hnir-btn:hover {
                transform: scale(1.04);
            }

            #${IDS.uiRoot}.is-menu-open .hnir-menu-button .hnir-btn {
                transform: rotate(90deg);
            }

            #${IDS.uiRoot}.is-menu-open .hnir-menu-button .hnir-btn:hover {
                transform: rotate(90deg) scale(1.04);
            }

            .hnir-segmented {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .hnir-chip {
                min-width: 74px;
                height: 32px;
                padding: 0 12px;
                border: 0;
                border-radius: 999px;
                background: transparent;
                color: rgba(45, 49, 55, 0.72);
                cursor: pointer;
                font-size: 13px;
                font-weight: 700;
                transition: background-color 150ms ease, color 150ms ease;
            }

            .hnir-chip.is-active {
                background: #303843;
                color: #f7f8fa;
            }

            .hnir-jump {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .hnir-jump input {
                width: 82px;
                height: 32px;
                padding: 0 10px;
                border: 0;
                border-radius: 999px;
                background: rgba(255, 255, 255, 0.72);
                color: #2d3137;
                font-size: 13px;
                font-weight: 700;
                outline: none;
                box-sizing: border-box;
            }

            .hnir-slider-wrap {
                display: flex;
                flex-direction: column;
                gap: 8px;
                width: 100%;
                min-width: 250px;
            }

            .hnir-slider-top {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 12px;
                color: var(--hnir-ui-text);
                font-size: 12px;
                font-weight: 700;
            }

            .hnir-slider-value {
                min-width: 48px;
                text-align: right;
                color: rgba(45, 49, 55, 0.76);
            }

            .hnir-slider-row {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .hnir-slider-row input[type="range"] {
                flex: 1;
                accent-color: #303843;
                cursor: pointer;
            }

            .hnir-color-picker {
                width: 260px;
                display: flex;
                flex-direction: column;
                gap: 10px;
            }

            .hnir-color-picker-top {
                display: flex;
                align-items: center;
                gap: 10px;
            }

            .hnir-color-preview {
                width: 34px;
                height: 34px;
                border-radius: 999px;
                border: 1px solid rgba(0, 0, 0, 0.16);
                box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35);
                flex: none;
            }

            .hnir-color-hex {
                flex: 1;
                height: 32px;
                padding: 0 12px;
                border: 0;
                border-radius: 999px;
                background: rgba(255, 255, 255, 0.72);
                color: #2d3137;
                font-size: 13px;
                font-weight: 700;
                outline: none;
                box-sizing: border-box;
            }

            .hnir-color-square {
    --hnir-picker-hue: 0;
    position: relative;
    height: 142px;
    border-radius: 18px;
    overflow: hidden;
    background: hsl(var(--hnir-picker-hue), 100%, 50%);
    cursor: pointer;
    box-shadow:
        inset 0 0 0 1px rgba(0, 0, 0, 0.12),
        0 8px 20px rgba(0, 0, 0, 0.12);
}

.hnir-color-square::before {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}

.hnir-color-square::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(to bottom, rgba(0, 0, 0, 0), #000);
}

            .hnir-color-knob {
                position: absolute;
                z-index: 2;
                width: 14px;
                height: 14px;
                border: 2px solid white;
                border-radius: 50%;
                box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.45);
                transform: translate(-50%, -50%);
                pointer-events: none;
            }

            .hnir-hue-row {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .hnir-hue-slider {
    flex: 1;
    height: 12px;
    border-radius: 999px;
    outline: none;
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    background: linear-gradient(
        to right,
        #ff0000 0%,
        #ffff00 16.6%,
        #00ff00 33.3%,
        #00ffff 50%,
        #0000ff 66.6%,
        #ff00ff 83.3%,
        #ff0000 100%
    );
    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.16);
}

.hnir-hue-slider::-webkit-slider-runnable-track {
    height: 12px;
    border-radius: 999px;
    background: linear-gradient(
        to right,
        #ff0000 0%,
        #ffff00 16.6%,
        #00ff00 33.3%,
        #00ffff 50%,
        #0000ff 66.6%,
        #ff00ff 83.3%,
        #ff0000 100%
    );
}

.hnir-hue-slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 18px;
    height: 18px;
    margin-top: -3px;
    border-radius: 50%;
    border: 2px solid white;
    background: #303843;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.35);
}

.hnir-hue-slider::-moz-range-track {
    height: 12px;
    border-radius: 999px;
    background: linear-gradient(
        to right,
        #ff0000 0%,
        #ffff00 16.6%,
        #00ff00 33.3%,
        #00ffff 50%,
        #0000ff 66.6%,
        #ff00ff 83.3%,
        #ff0000 100%
    );
}

.hnir-hue-slider::-moz-range-thumb {
    width: 18px;
    height: 18px;
    border-radius: 50%;
    border: 2px solid white;
    background: #303843;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.35);
}

            #${IDS.progressBar} {
                position: fixed;
                top: 0;
                left: 0;
                height: 4px;
                width: 0%;
                z-index: 2147483647;
                background: var(--hnir-ui-fill);
                transition: width 80ms linear;
            }

            #${IDS.pageIndicator} {
                position: fixed;
                left: 20px;
                bottom: 20px;
                z-index: 2147483647;
                padding: 8px 12px;
                border-radius: 999px;
                border: 1px solid var(--hnir-ui-stroke);
                background: var(--hnir-ui-surface);
                color: var(--hnir-ui-text);
                font-family: "Segoe UI", sans-serif;
                font-size: 13px;
                font-weight: 700;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                pointer-events: none;
                user-select: none;
            }

            @media (max-width: 700px) {
                #${IDS.launcher},
                #${IDS.uiRoot} {
                    right: 12px;
                    bottom: 12px;
                }

                #${IDS.pageIndicator} {
                    left: 12px;
                    bottom: 12px;
                }

                .hnir-stack-item.is-open .hnir-panel {
                    max-width: calc(100vw - 88px);
                }

                .hnir-stack-item.is-open .hnir-panel.hnir-panel-vertical {
                    min-width: min(270px, calc(100vw - 102px));
                }

                .hnir-chip {
                    min-width: 58px;
                }

                .hnir-mode-manga .hnir-image {
                    max-width: calc((100vw - 36px) / 2);
                }
            }
        `;

        document.head.appendChild(style);
    }

    function clampPage(pageNo) {
        const parsed = parseInt(pageNo, 10);

        if (!Number.isFinite(parsed)) return 1;

        return Math.max(1, Math.min(totalPages, parsed));
    }

    function getFilename(url) {
        try {
            return String(url).split('/').pop().split('?')[0].split('#')[0];
        } catch {
            return '';
        }
    }

    function normalizeUrlish(url) {
        return String(url || '')
            .trim()
            .replace(/^https?:\/\//i, '')
            .replace(/^\/\//, '')
            .split('?')[0]
            .split('#')[0];
    }

    function normalizeHex(value) {
        let hex = String(value || '').trim();

        if (!hex) return null;

        if (!hex.startsWith('#')) {
            hex = '#' + hex;
        }

        if (/^#[0-9a-f]{3}$/i.test(hex)) {
            hex = '#' + hex.slice(1).split('').map(ch => ch + ch).join('');
        }

        if (!/^#[0-9a-f]{6}$/i.test(hex)) {
            return null;
        }

        return hex.toLowerCase();
    }

    function hexToHsv(hex) {
        const fixed = normalizeHex(hex) || CONFIG.defaultThemeColor;

        const r = parseInt(fixed.slice(1, 3), 16) / 255;
        const g = parseInt(fixed.slice(3, 5), 16) / 255;
        const b = parseInt(fixed.slice(5, 7), 16) / 255;

        const max = Math.max(r, g, b);
        const min = Math.min(r, g, b);
        const delta = max - min;

        let h = 0;

        if (delta !== 0) {
            if (max === r) {
                h = 60 * (((g - b) / delta) % 6);
            } else if (max === g) {
                h = 60 * (((b - r) / delta) + 2);
            } else {
                h = 60 * (((r - g) / delta) + 4);
            }
        }

        if (h < 0) h += 360;

        const s = max === 0 ? 0 : delta / max;
        const v = max;

        return { h, s, v };
    }

    function hsvToHex(h, s, v) {
        const c = v * s;
        const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
        const m = v - c;

        let r1 = 0;
        let g1 = 0;
        let b1 = 0;

        if (h >= 0 && h < 60) {
            r1 = c; g1 = x; b1 = 0;
        } else if (h >= 60 && h < 120) {
            r1 = x; g1 = c; b1 = 0;
        } else if (h >= 120 && h < 180) {
            r1 = 0; g1 = c; b1 = x;
        } else if (h >= 180 && h < 240) {
            r1 = 0; g1 = x; b1 = c;
        } else if (h >= 240 && h < 300) {
            r1 = x; g1 = 0; b1 = c;
        } else {
            r1 = c; g1 = 0; b1 = x;
        }

        const toHex = value => {
            const num = Math.round((value + m) * 255);
            return num.toString(16).padStart(2, '0');
        };

        return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
    }

})();