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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();