Hentaizap Vertical Scroll

View hentaizap galleries in a vertical scroll instead of clicking page by page, with a zip download option

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Advertisement:

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

Advertisement:

// ==UserScript==
// @name         Hentaizap Vertical Scroll
// @namespace    https://hentaizap.com
// @version      5.0
// @author       JoyArz
// @description  View hentaizap galleries in a vertical scroll instead of clicking page by page, with a zip download option
// @match        https://hentaizap.com/gallery/*/
// @grant        GM_xmlhttpRequest
// @connect      hentaizap.com
// @connect      *.hentaizap.com
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';
    console.log('[VS Debug] Script loaded! Host:', window.location.hostname);

    const WIDTH_STORAGE_KEY = 'hentaizap_vs_width_percent';
    const MOBILE_BREAKPOINT = 1024;

    const STYLE = `
        body { background: #0f0f14 !important; }
        #content { background: #0f0f14 !important; }
        .vs-container { position: relative; width: 100%; max-width: 100vw; margin: 0 auto; padding-top: 20px; }
        .vs-page { position: relative; width: 100%; display: flex; justify-content: center; align-items: flex-start; background: #0f0f14; margin-bottom: 8px; min-height: 100px; }
        .vs-page img { max-width: 100%; max-height: 100vh; width: auto; height: auto; object-fit: contain; display: block; background: #1a1a22; }
        .vs-page img.loading { opacity: 0.3; background: transparent; }
        .vs-page img.uniform-width { height: auto; }
        .vs-loading { position: fixed; bottom: 24px; left: 24px; background: rgba(20, 20, 26, 0.6); border: 1px solid rgba(255, 255, 255, 0.08); backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); color: #e8e8ec; padding: 8px 16px; border-radius: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px; z-index: 9999; text-align: center; }

        .vs-nav { position: fixed; bottom: 24px; right: 24px; display: flex; flex-direction: column; gap: 8px; z-index: 9998; padding: 8px; background: rgba(20, 20, 26, 0.55); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 22px; backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); }
        .vs-nav button { width: 38px; height: 38px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.06); color: #e8e8ec; font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease; }
        .vs-nav button:hover { background: rgba(255, 51, 102, 0.18); color: #ff3366; transform: scale(1.06); }
        .vs-nav button:active { transform: scale(0.94); }

        .vs-nav-top { position: fixed; top: 24px; right: 24px; z-index: 9998; display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: rgba(20, 20, 26, 0.55); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 18px; backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
        .vs-width-control { display: flex; align-items: center; gap: 6px; }
        .vs-width-control label { font-size: 11px; letter-spacing: 0.02em; color: #9a9aa5; user-select: none; }
        .vs-width-control input[type="number"] { width: 44px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.10); border-radius: 8px; color: #e8e8ec; font-size: 12px; padding: 5px 4px; text-align: center; }
        .vs-width-control input[type="number"]:focus { outline: none; border-color: rgba(255, 51, 102, 0.6); background: rgba(255, 255, 255, 0.09); }
        .vs-width-control input[type="number"]::-webkit-outer-spin-button,
        .vs-width-control input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
        .vs-width-control input[type="number"] { -moz-appearance: textfield; }
        .vs-width-control .vs-width-unit { font-size: 11px; color: #6f6f78; }
        .vs-width-control button { width: 24px; height: 24px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.06); color: #9a9aa5; font-size: 12px; line-height: 1; cursor: pointer; transition: background 0.18s ease, color 0.18s ease; }
        .vs-width-control button:hover { background: rgba(255, 51, 102, 0.18); color: #ff3366; }
        .vs-nav-top #vs-show-failed { padding: 5px 10px; border-radius: 10px; border: none; background: rgba(255, 51, 102, 0.16); color: #ff7a93; font-size: 11px; font-weight: 500; cursor: pointer; transition: background 0.18s ease; }
        .vs-nav-top #vs-show-failed:hover { background: rgba(255, 51, 102, 0.28); }

        .vs-download-wrap { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin: 14px 0 20px; padding: 14px 16px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 16px; backdrop-filter: blur(10px) saturate(140%); -webkit-backdrop-filter: blur(10px) saturate(140%); max-width: 280px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
        .vs-download-btn { display: flex; align-items: center; gap: 8px; padding: 9px 16px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.10); background: rgba(255, 255, 255, 0.05); color: #e8e8ec; font-size: 13px; font-family: inherit; cursor: pointer; transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease; }
        .vs-download-btn:hover { background: rgba(255, 51, 102, 0.14); border-color: rgba(255, 51, 102, 0.4); }
        .vs-download-btn:active { transform: scale(0.98); }
        .vs-download-btn:disabled { opacity: 0.55; cursor: default; }
        .vs-download-icon { font-size: 14px; }
        .vs-download-progress { display: flex; align-items: center; gap: 10px; width: 100%; }
        .vs-download-progress-track { flex: 1; height: 4px; border-radius: 4px; background: rgba(255, 255, 255, 0.08); overflow: hidden; }
        .vs-download-progress-bar { height: 100%; width: 0%; background: #ff3366; transition: width 0.2s ease; }
        .vs-download-progress-text { font-size: 11px; color: #9a9aa5; white-space: nowrap; }

        .vs-error { color: #ff3366; padding: 20px; text-align: center; }
        .vs-page-failed { border: 2px dashed #ff3366; }
        .vs-page-failed::after { content: 'Failed'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #ff3366; font-size: 24px; font-weight: bold; }
        .hidden { display: none !important; }
    `;

    const NAV_HTML = `
        <div class="vs-nav">
            <button id="vs-scroll-top" title="Scroll to top">▲</button>
            <button id="vs-scroll-bottom" title="Scroll to bottom">▼</button>
            <button id="vs-retry-failed" title="Retry failed pages">↻</button>
        </div>
        <div class="vs-nav-top">
            <div class="vs-width-control">
                <label for="vs-width-input">Width</label>
                <input type="number" id="vs-width-input" min="10" max="400" step="1" inputmode="numeric" placeholder="auto" title="Custom width as a percent. Leave empty for the device default.">
                <span class="vs-width-unit">%</span>
                <button id="vs-width-reset" title="Reset to device default">↺</button>
            </div>
            <button id="vs-show-failed"></button>
        </div>
    `;

    const CRC32_TABLE = (() => {
        const table = new Uint32Array(256);
        for (let n = 0; n < 256; n++) {
            let c = n;
            for (let k = 0; k < 8; k++) {
                c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
            }
            table[n] = c;
        }
        return table;
    })();

    function crc32(bytes) {
        let crc = 0xFFFFFFFF;
        for (let i = 0; i < bytes.length; i++) {
            crc = CRC32_TABLE[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
        }
        return (crc ^ 0xFFFFFFFF) >>> 0;
    }

    function asciiBytes(str) {
        const bytes = new Uint8Array(str.length);
        for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xFF;
        return bytes;
    }

    function toDosDateTime(date) {
        const dosTime = ((date.getHours() & 0x1F) << 11) | ((date.getMinutes() & 0x3F) << 5) | (Math.floor(date.getSeconds() / 2) & 0x1F);
        const dosDate = (((date.getFullYear() - 1980) & 0x7F) << 9) | (((date.getMonth() + 1) & 0xF) << 5) | (date.getDate() & 0x1F);
        return { dosTime, dosDate };
    }

    // Builds an uncompressed (STORE method) ZIP file entirely with typed
    // arrays. No compression, no third-party library, nothing async, so
    // there is no Promise to hang on. files: [{ name, data: ArrayBuffer }]
    function buildStoredZip(files) {
        const { dosTime, dosDate } = toDosDateTime(new Date());
        const localParts = [];
        const centralParts = [];
        let offset = 0;

        for (const file of files) {
            const nameBytes = asciiBytes(file.name);
            const dataBytes = new Uint8Array(file.data);
            const crc = crc32(dataBytes);
            const size = dataBytes.length;

            const local = new DataView(new ArrayBuffer(30));
            local.setUint32(0, 0x04034b50, true);
            local.setUint16(4, 20, true);
            local.setUint16(6, 0, true);
            local.setUint16(8, 0, true);
            local.setUint16(10, dosTime, true);
            local.setUint16(12, dosDate, true);
            local.setUint32(14, crc, true);
            local.setUint32(18, size, true);
            local.setUint32(22, size, true);
            local.setUint16(26, nameBytes.length, true);
            local.setUint16(28, 0, true);
            localParts.push(new Uint8Array(local.buffer), nameBytes, dataBytes);

            const central = new DataView(new ArrayBuffer(46));
            central.setUint32(0, 0x02014b50, true);
            central.setUint16(4, 20, true);
            central.setUint16(6, 20, true);
            central.setUint16(8, 0, true);
            central.setUint16(10, 0, true);
            central.setUint16(12, dosTime, true);
            central.setUint16(14, dosDate, true);
            central.setUint32(16, crc, true);
            central.setUint32(20, size, true);
            central.setUint32(24, size, true);
            central.setUint16(28, nameBytes.length, true);
            central.setUint16(30, 0, true);
            central.setUint16(32, 0, true);
            central.setUint16(34, 0, true);
            central.setUint16(36, 0, true);
            central.setUint32(38, 0, true);
            central.setUint32(42, offset, true);
            centralParts.push(new Uint8Array(central.buffer), nameBytes);

            offset += 30 + nameBytes.length + size;
        }

        const centralStart = offset;
        const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);

        const eocd = new DataView(new ArrayBuffer(22));
        eocd.setUint32(0, 0x06054b50, true);
        eocd.setUint16(4, 0, true);
        eocd.setUint16(6, 0, true);
        eocd.setUint16(8, files.length, true);
        eocd.setUint16(10, files.length, true);
        eocd.setUint32(12, centralSize, true);
        eocd.setUint32(16, centralStart, true);
        eocd.setUint16(20, 0, true);

        return new Blob([...localParts, ...centralParts, new Uint8Array(eocd.buffer)], { type: 'application/zip' });
    }

    class HentaizapScroll {
        constructor() {
            this.galleryId = null;
            this.pages = 0;
            this.baseWidth = 450;
            this.widthPercent = this.loadSavedWidth(); // null = device default, number = manual override
            this.container = null;
            this.loadedPages = new Set();
            this.failedPages = new Set();
            this.loadedCount = 0;
            this.initialized = false;

            this.batchSize = 10;
            this.currentBatchStart = 0;
            this.isLoadingBatches = false;
            this.maxRetries = 2;

            this.hentaizapServer = null;
            this.hentaizapDir = null;
            this.hentaizapHash = null;
        }

        extractGalleryInfo() {
            console.log('[VS Debug] extractGalleryInfo called');
            const pathMatch = window.location.pathname.match(/\/gallery\/(\d+)/);
            if (pathMatch) this.galleryId = pathMatch[1];

            const loadServer = document.getElementById('load_server');
            const loadDir = document.getElementById('load_dir');
            const loadId = document.getElementById('load_id');
            const loadPages = document.getElementById('load_pages');

            if (loadServer) this.hentaizapServer = loadServer.value;
            if (loadDir) this.hentaizapDir = loadDir.value;
            if (loadId) this.hentaizapHash = loadId.value;
            if (loadPages) this.pages = parseInt(loadPages.value, 10) || 0;

            if (this.pages === 0) {
                const pageNav = document.querySelector('.page-nav, .pagination, [class*="page"]');
                if (pageNav) {
                    const nums = pageNav.textContent.match(/\d+/g);
                    if (nums && nums.length > 0) {
                        this.pages = parseInt(nums[nums.length - 1], 10);
                    }
                }
            }

            if (this.pages === 0) {
                const infoPg = document.querySelector('.info_pg');
                if (infoPg) {
                    const m = infoPg.textContent.match(/Pages?:\s*(\d+)/i);
                    if (m) this.pages = parseInt(m[1], 10);
                }
            }

            console.log('[VS Debug] Final result:', { galleryId: this.galleryId, pages: this.pages });
        }

        getImageUrls(pageNum) {
            if (!this.hentaizapServer || !this.hentaizapDir || !this.hentaizapHash) {
                return [];
            }
            const server = `m${this.hentaizapServer}`;
            const base = `https://${server}.hentaizap.com/${this.hentaizapDir}/${this.hentaizapHash}/${pageNum}`;
            return [`${base}.webp`, `${base}.jpg`, `${base}.jpeg`, `${base}.png`];
        }

        getImageUrl(pageNum) {
            return this.getImageUrls(pageNum)[0];
        }

        isMobileOrTablet() {
            return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
        }

        loadSavedWidth() {
            try {
                const saved = localStorage.getItem(WIDTH_STORAGE_KEY);
                if (saved !== null && saved !== '') {
                    const parsed = parseFloat(saved);
                    if (!isNaN(parsed) && parsed > 0) return parsed;
                }
            } catch (e) {
                console.log('[VS Debug] Could not read saved width:', e);
            }
            return null;
        }

        saveWidth(percent) {
            try {
                localStorage.setItem(WIDTH_STORAGE_KEY, String(percent));
            } catch (e) {
                console.log('[VS Debug] Could not save width:', e);
            }
        }

        clearSavedWidth() {
            try {
                localStorage.removeItem(WIDTH_STORAGE_KEY);
            } catch (e) {
                console.log('[VS Debug] Could not clear saved width:', e);
            }
        }

        setWidthPercent(percent) {
            this.widthPercent = percent;
            this.saveWidth(percent);
            this.normalizeWidths();
        }

        resetWidthPercent() {
            this.widthPercent = null;
            this.clearSavedWidth();
            this.normalizeWidths();
        }

        getMangaTitle() {
            const candidates = ['h1.title', 'h1', '.gallery-title', '.entry-title', '.gallery_title', '.gallery-name', '.title'];
            for (const sel of candidates) {
                const el = document.querySelector(sel);
                const text = el && el.textContent ? el.textContent.trim() : '';
                if (text && !/^Pages?:/i.test(text)) {
                    return text;
                }
            }
            let title = (document.title || '').trim();
            title = title.replace(/\s*[-|]\s*Hentaizap.*$/i, '').trim();
            if (title) return title;
            return `gallery-${this.galleryId || 'unknown'}`;
        }

        sanitizeFilename(name) {
            const cleaned = name
                .replace(/[\\/:*?"<>|]+/g, ' ')
                .replace(/\s+/g, ' ')
                .trim()
                .slice(0, 150);
            return cleaned || 'manga';
        }

        addDownloadButton() {
            console.log('[VS Debug] addDownloadButton called');
            const wrap = document.createElement('div');
            wrap.className = 'vs-download-wrap';
            wrap.id = 'vs-download-wrap';
            wrap.innerHTML = `
                <button id="vs-download-zip" class="vs-download-btn" title="Download all pages as a zip file">
                    <span class="vs-download-icon">⬇</span>
                    <span class="vs-download-label">Download ZIP</span>
                </button>
                <div class="vs-download-progress hidden" id="vs-download-progress">
                    <div class="vs-download-progress-track">
                        <div class="vs-download-progress-bar" id="vs-download-progress-bar"></div>
                    </div>
                    <span class="vs-download-progress-text" id="vs-download-progress-text">0 / 0</span>
                </div>
            `;

            const infoPg = document.querySelector('.info_pg');
            if (infoPg && infoPg.parentNode) {
                infoPg.insertAdjacentElement('afterend', wrap);
                console.log('[VS Debug] Download button placed after .info_pg');
            } else if (this.container && this.container.parentNode) {
                this.container.parentNode.insertBefore(wrap, this.container);
                console.log('[VS Debug] .info_pg not found, placed download button above the scroll view');
            } else {
                document.body.appendChild(wrap);
            }

            const btn = document.getElementById('vs-download-zip');
            btn.addEventListener('click', () => this.downloadZip(btn));
        }

        async downloadZip(btn) {
            if (this.isDownloadingZip) return;

            console.log('[VS Debug] Download ZIP clicked. GM_xmlhttpRequest:', typeof GM_xmlhttpRequest);

            if (typeof GM_xmlhttpRequest !== 'function') {
                console.log('[VS Debug] GM_xmlhttpRequest unavailable, cannot zip');
                alert('Zip download needs a userscript manager permission update. Open this script in your userscript manager dashboard, look for a pending permission/host-access prompt, approve it, then reload this page.');
                return;
            }

            this.isDownloadingZip = true;
            btn.disabled = true;

            const progressWrap = document.getElementById('vs-download-progress');
            const progressBar = document.getElementById('vs-download-progress-bar');
            const progressText = document.getElementById('vs-download-progress-text');
            if (progressWrap) progressWrap.classList.remove('hidden');

            const total = this.pages;
            let done = 0;
            let responseReceived = false;
            const failed = [];
            const zipFiles = [];

            const updateProgress = () => {
                if (progressBar) progressBar.style.width = `${Math.round((done / total) * 100)}%`;
                if (progressText) progressText.textContent = `${done} / ${total}`;
            };
            updateProgress();

            const stallWatchdog = setTimeout(() => {
                if (!responseReceived) {
                    console.log('[VS Debug] No image response after 8s. This usually means your userscript manager has not granted host access for hentaizap.com / *.hentaizap.com yet. Check the manager dashboard for a pending permission prompt on this script, approve it, then click Download ZIP again.');
                    if (progressText) progressText.textContent = 'Waiting on host permission...';
                }
            }, 8000);

            const fetchPage = (pageNum) => new Promise((resolve) => {
                const urls = this.getImageUrls(pageNum);

                const tryUrl = (urlIndex) => {
                    if (urlIndex >= urls.length) {
                        console.log('[VS Debug] zip: page', pageNum, 'failed in every format');
                        failed.push(pageNum);
                        done++;
                        updateProgress();
                        resolve();
                        return;
                    }

                    console.log('[VS Debug] zip: requesting page', pageNum, urls[urlIndex]);

                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: urls[urlIndex],
                        responseType: 'arraybuffer',
                        timeout: 20000,
                        onload: (res) => {
                            responseReceived = true;
                            console.log('[VS Debug] zip: page', pageNum, 'status', res.status, 'bytes', res.response ? res.response.byteLength : 0);
                            if (res.status >= 200 && res.status < 300 && res.response) {
                                const ext = urls[urlIndex].split('.').pop();
                                const name = `${String(pageNum).padStart(3, '0')}.${ext}`;
                                zipFiles.push({ name, data: res.response });
                                done++;
                                updateProgress();
                                resolve();
                            } else {
                                tryUrl(urlIndex + 1);
                            }
                        },
                        onerror: (err) => {
                            responseReceived = true;
                            console.log('[VS Debug] zip: page', pageNum, 'request error', err);
                            tryUrl(urlIndex + 1);
                        },
                        ontimeout: () => {
                            console.log('[VS Debug] zip: page', pageNum, 'timed out');
                            tryUrl(urlIndex + 1);
                        }
                    });
                };

                tryUrl(0);
            });

            const concurrency = 5;
            let cursor = 1;
            const worker = async () => {
                while (cursor <= total) {
                    const pageNum = cursor++;
                    await fetchPage(pageNum);
                }
            };

            try {
                await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker));
            } catch (e) {
                console.log('[VS Debug] zip: worker loop threw', e);
            }

            clearTimeout(stallWatchdog);
            zipFiles.sort((a, b) => a.name.localeCompare(b.name));
            console.log('[VS Debug] zip: all page requests settled, done:', done, 'failed:', failed.length, 'files ready:', zipFiles.length);

            if (progressText) progressText.textContent = 'Building zip...';

            try {
                console.log('[VS Debug] zip: building archive synchronously, no library, no async build step');
                const blob = buildStoredZip(zipFiles);
                console.log('[VS Debug] zip: blob built, size', blob.size);
                const filename = `${this.sanitizeFilename(this.getMangaTitle())}.zip`;
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = filename;
                document.body.appendChild(a);
                a.click();
                a.remove();
                setTimeout(() => URL.revokeObjectURL(url), 2000);
                console.log('[VS Debug] Zip download triggered:', filename);
            } catch (e) {
                console.log('[VS Debug] Zip generation failed:', e);
                if (progressText) progressText.textContent = 'Zip build failed, check console';
            }

            if (progressText) {
                progressText.textContent = failed.length > 0
                    ? `Done, ${failed.length} page(s) skipped`
                    : 'Done';
            }
            if (failed.length > 0) {
                console.log('[VS Debug] Pages skipped from zip:', failed);
            }

            btn.disabled = false;
            this.isDownloadingZip = false;

            setTimeout(() => {
                if (progressWrap) progressWrap.classList.add('hidden');
            }, 3000);
        }

        init() {
            console.log('[VS Debug] init called');
            if (this.initialized) return;
            this.initialized = true;

            this.extractGalleryInfo();

            if (!this.galleryId || !this.pages) {
                console.log('[VS Debug] Gallery info missing');
                this.showError('Could not detect gallery info');
                return;
            }

            this.injectStyles();
            this.createVerticalView();
            this.addDownloadButton();
            this.addNavigation();
            this.setupKeyboardNav();
            this.loadVisiblePages();
        }

        showError(msg) {
            const errorDiv = document.createElement('div');
            errorDiv.className = 'vs-error';
            errorDiv.textContent = msg;
            const content = document.querySelector('#content');
            if (content) {
                content.innerHTML = '';
                content.appendChild(errorDiv);
            }
        }

        injectStyles() {
            console.log('[VS Debug] injectStyles called');
            const styleEl = document.createElement('style');
            styleEl.id = 'vs-styles';
            styleEl.textContent = STYLE;
            document.head.appendChild(styleEl);
        }

        createVerticalView() {
            console.log('[VS Debug] createVerticalView called, pages:', this.pages);

            const thumbnailContainer = document.querySelector('#thumbnail-container');
            const hentaizapThumbs = document.querySelectorAll('.thumbnail, .thumb, [class*="thumb"]');

            // Anchor is the element marking where the thumbnail grid sits.
            // The scroll view gets inserted at this exact spot, so it
            // replaces the grid in place instead of landing at the bottom
            // of the page below unrelated content.
            const anchor = thumbnailContainer || (hentaizapThumbs.length ? hentaizapThumbs[0] : null);

            this.container = document.createElement('div');
            this.container.className = 'vs-container';

            for (let i = 1; i <= this.pages; i++) {
                const pageEl = document.createElement('div');
                pageEl.className = 'vs-page';
                pageEl.dataset.page = i;
                pageEl.innerHTML = `<img class="loading" data-src="${this.getImageUrl(i)}" alt="Page ${i}">`;
                this.container.appendChild(pageEl);
            }

            if (anchor && anchor.parentNode) {
                anchor.parentNode.insertBefore(this.container, anchor);
                console.log('[VS Debug] Container inserted at thumbnail position');
            } else {
                const mainContent = document.querySelector('#content, main, .content, [role="main"]');
                if (mainContent) {
                    mainContent.appendChild(this.container);
                } else {
                    document.body.appendChild(this.container);
                }
                console.log('[VS Debug] No thumbnail anchor found, fell back to appending');
            }

            // Hide the original thumbnails rather than removing them, so
            // navigation between galleries can detect and reset them again.
            if (thumbnailContainer) thumbnailContainer.classList.add('hidden');
            hentaizapThumbs.forEach(el => el.classList.add('hidden'));

            const loading = document.createElement('div');
            loading.className = 'vs-loading';
            loading.id = 'vs-loading';
            loading.textContent = `Loading...`;
            document.body.appendChild(loading);

            // Page count is fixed to this.pages. The loader below stops
            // once every page is loaded or failed, no infinite loading.
            setTimeout(() => {
                this.loadInitialBatch();
            }, 500);
        }

        addNavigation() {
            console.log('[VS Debug] addNavigation called');
            const navDiv = document.createElement('div');
            navDiv.innerHTML = NAV_HTML;
            document.body.appendChild(navDiv);

            document.getElementById('vs-scroll-top').addEventListener('click', () => {
                window.scrollTo({ top: 0, behavior: 'smooth' });
            });

            document.getElementById('vs-scroll-bottom').addEventListener('click', () => {
                window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
            });

            document.getElementById('vs-retry-failed').addEventListener('click', () => {
                this.retryFailedPages();
            });

            const widthInput = document.getElementById('vs-width-input');
            widthInput.value = this.widthPercent !== null ? this.widthPercent : '';

            const applyWidthFromInput = () => {
                const raw = widthInput.value.trim();
                if (raw === '') {
                    this.resetWidthPercent();
                    return;
                }
                const value = parseFloat(raw);
                if (!isNaN(value) && value > 0) {
                    this.setWidthPercent(value);
                } else {
                    widthInput.value = this.widthPercent !== null ? this.widthPercent : '';
                }
            };

            widthInput.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') {
                    applyWidthFromInput();
                    widthInput.blur();
                }
            });
            widthInput.addEventListener('blur', applyWidthFromInput);

            document.getElementById('vs-width-reset').addEventListener('click', () => {
                widthInput.value = '';
                this.resetWidthPercent();
            });

            window.addEventListener('resize', () => {
                if (this.widthPercent === null) {
                    this.normalizeWidths();
                }
            });

            this.updateFailedButton();
        }

        updateFailedButton() {
            const btn = document.getElementById('vs-show-failed');
            if (!btn) return;
            const count = this.failedPages.size;
            btn.textContent = count > 0 ? `Failed: ${count}` : '';
            btn.style.display = count > 0 ? 'block' : 'none';
        }

        retryFailedPages() {
            console.log('[VS Debug] retryFailedPages called, failed count:', this.failedPages.size);
            if (this.failedPages.size === 0) return;
            const pages = Array.from(this.container.querySelectorAll('.vs-page-failed'));
            this.failedPages.clear();
            pages.forEach(pageEl => {
                pageEl.classList.remove('vs-page-failed');
                const img = pageEl.querySelector('img');
                img.style.opacity = '';
                img.alt = `Page ${pageEl.dataset.page}`;
                this.loadImageWithRetry(pageEl, null, () => {});
            });
            this.updateFailedButton();
        }

        setupKeyboardNav() {
            let scrollScheduled = false;
            window.addEventListener('scroll', () => {
                if (scrollScheduled) return;
                scrollScheduled = true;
                requestAnimationFrame(() => {
                    this.loadVisiblePages();
                    scrollScheduled = false;
                });
            }, { passive: true });

            this.visibilityInterval = setInterval(() => {
                this.loadVisiblePages();
            }, 500);
        }

        loadInitialBatch() {
            console.log('[VS Debug] loadInitialBatch called');
            const loading = document.getElementById('vs-loading');
            const pages = Array.from(this.container.querySelectorAll('.vs-page'));
            const toLoad = pages.slice(0, Math.min(this.batchSize, this.pages));
            console.log('[VS Debug] Loading initial batch of', toLoad.length, 'pages');

            this.loadBatch(toLoad, loading, () => {
                this.currentBatchStart = toLoad.length;
                console.log('[VS Debug] Initial batch complete, batchStart:', this.currentBatchStart);
                this.loadRemainingBatches();
            });
        }

        loadBatch(pageEls, loading, onComplete) {
            let loaded = 0;
            const total = pageEls.length;
            console.log('[VS Debug] loadBatch called with', total, 'elements');
            if (total === 0) { if (onComplete) onComplete(); return; }

            pageEls.forEach((pageEl) => {
                this.loadImageWithRetry(pageEl, loading, () => {
                    loaded++;
                    if (loaded >= total && onComplete) onComplete();
                });
            });
        }

        loadImageWithRetry(pageEl, loading, onDone) {
            const img = pageEl.querySelector('img');
            const pageNum = parseInt(pageEl.dataset.page, 10);
            const urls = this.getImageUrls(pageNum);
            let retryCount = 0;
            console.log('[VS Debug] loadImageWithRetry page', pageNum, 'urls:', urls);

            const tryLoad = (urlIndex) => {
                if (urlIndex >= urls.length) {
                    console.log('[VS Debug] All formats failed for page', pageNum);
                    this.markFailed(pageEl, pageNum, loading);
                    onDone();
                    return;
                }

                console.log('[VS Debug] Trying URL index', urlIndex, ':', urls[urlIndex]);
                img.onload = () => {
                    img.classList.remove('loading');
                    this.loadedPages.add(pageNum);
                    this.loadedCount++;
                    if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
                    console.log('[VS Debug] Page', pageNum, 'loaded successfully');
                    this.normalizeWidths();
                    this.checkAllLoaded();
                    onDone();
                };

                img.onerror = () => {
                    console.log('[VS Debug] Page', pageNum, 'failed, urlIndex:', urlIndex, 'retryCount:', retryCount);
                    if (retryCount < this.maxRetries) {
                        retryCount++;
                        console.log('[VS Debug] Retrying page', pageNum, 'attempt', retryCount);
                        setTimeout(() => tryLoad(urlIndex), 300 * retryCount);
                    } else {
                        retryCount = 0;
                        console.log('[VS Debug] Trying next format for page', pageNum);
                        tryLoad(urlIndex + 1);
                    }
                };

                img.src = urls[urlIndex];
            };

            tryLoad(0);
        }

        markFailed(pageEl, pageNum, loading) {
            this.failedPages.add(pageNum);
            this.loadedPages.add(pageNum);
            this.loadedCount++;
            if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
            pageEl.classList.add('vs-page-failed');
            const img = pageEl.querySelector('img');
            img.classList.remove('loading');
            img.alt = `Failed page ${pageNum}`;
            img.style.opacity = '0.3';
            img.dataset.src = '';
            this.checkAllLoaded();
            this.updateFailedButton();
        }

        checkAllLoaded() {
            const totalProcessed = this.loadedPages.size + this.failedPages.size;
            if (totalProcessed >= this.pages) {
                const loading = document.getElementById('vs-loading');
                if (loading) loading.classList.add('hidden');
                this.normalizeWidths();
                if (this.visibilityInterval) {
                    clearInterval(this.visibilityInterval);
                    this.visibilityInterval = null;
                    console.log('[VS Debug] All pages loaded, stopped the periodic visibility check');
                }
            }
        }

        normalizeWidths() {
            const images = this.container.querySelectorAll('.vs-page img');
            // No manual override: tablets and phones get the full device
            // width, desktop keeps the original 450px default (100%).
            const effectivePercent = this.widthPercent !== null
                ? this.widthPercent
                : (this.isMobileOrTablet() ? null : 100);

            images.forEach(img => {
                if (effectivePercent === null) {
                    img.classList.remove('uniform-width');
                    img.style.width = '';
                    img.style.maxWidth = '';
                } else {
                    const targetWidth = Math.round(this.baseWidth * effectivePercent / 100);
                    img.classList.add('uniform-width');
                    img.style.width = targetWidth + 'px';
                    img.style.maxWidth = targetWidth + 'px';
                }
            });
        }

        loadRemainingBatches() {
            console.log('[VS Debug] loadRemainingBatches called, batchStart:', this.currentBatchStart, 'pages:', this.pages);
            if (this.isLoadingBatches) return;
            if (this.currentBatchStart >= this.pages) {
                console.log('[VS Debug] All batches loaded, stopping');
                this.checkAllLoaded();
                return;
            }

            this.isLoadingBatches = true;
            const pages = Array.from(this.container.querySelectorAll('.vs-page'));
            const nextBatch = pages.slice(this.currentBatchStart, this.currentBatchStart + this.batchSize);
            console.log('[VS Debug] Loading next batch of', nextBatch.length, 'pages');

            if (nextBatch.length === 0) {
                this.isLoadingBatches = false;
                this.checkAllLoaded();
                return;
            }

            this.loadBatch(nextBatch, null, () => {
                this.currentBatchStart += nextBatch.length;
                console.log('[VS Debug] Batch complete, new batchStart:', this.currentBatchStart);
                this.isLoadingBatches = false;
                this.checkAllLoaded();
                if (this.currentBatchStart < this.pages) {
                    setTimeout(() => this.loadRemainingBatches(), 100);
                }
            });
        }

        loadVisiblePages() {
            if (!this.container) return;
            if (this.loadedPages.size >= this.pages) return;

            const viewportHeight = window.innerHeight;
            const scrollY = window.scrollY;
            const pages = Array.from(this.container.querySelectorAll('.vs-page'));
            const currentPage = Math.floor((scrollY + viewportHeight) / viewportHeight);

            pages.forEach(pageEl => {
                const pageNum = parseInt(pageEl.dataset.page, 10);
                if (this.loadedPages.has(pageNum)) return;

                if (pageNum <= currentPage + 3) {
                    const img = pageEl.querySelector('img');
                    if (img && img.dataset.src) {
                        console.log('[VS Debug] Loading visible page', pageNum);
                        this.loadedPages.add(pageNum);
                        this.loadImageWithRetry(pageEl, null, () => {});
                    }
                }
            });
        }
    }

    function initScript() {
        console.log('[VS Debug] initScript called, pathname:', window.location.pathname);
        if (!window.location.pathname.match(/^\/gallery\/\d+/)) {
            console.log('[VS Debug] URL does not match gallery pattern, skipping');
            return;
        }

        const existingStyles = document.querySelector('#vs-styles');
        if (existingStyles) existingStyles.remove();

        const existingNav = document.querySelector('.vs-nav');
        if (existingNav) existingNav.remove();

        const existingNavTop = document.querySelector('.vs-nav-top');
        if (existingNavTop) existingNavTop.remove();

        const existingLoading = document.querySelector('#vs-loading');
        if (existingLoading) existingLoading.remove();

        const existingDownloadWrap = document.querySelector('#vs-download-wrap');
        if (existingDownloadWrap) existingDownloadWrap.remove();

        const vs = new HentaizapScroll();
        let initAttempts = 0;
        const maxInitAttempts = 20;

        const tryInit = () => {
            const content = document.querySelector('#content, main, .content, [role="main"]');
            const hasThumbs = document.querySelector('.thumbnail, .thumb, [class*="thumb"], #thumbnail-container, #ap_thumbs, .gp_th');
            console.log('[VS Debug] tryInit - content:', !!content, 'thumbs:', !!hasThumbs, 'initialized:', vs.initialized);
            if ((content || hasThumbs) && !vs.initialized) {
                console.log('[VS Debug] Calling vs.init()');
                vs.init();
            }
        };

        const observer = new MutationObserver((mutations) => {
            if (vs.initialized) return;
            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    for (const node of mutation.addedNodes) {
                        if (node.nodeName === 'SECTION' || node.nodeName === 'DIV') {
                            if (node.matches?.('.thumbnail, .thumb, [class*="thumb"], #ap_thumbs, .gp_th') ||
                               node.querySelector?.('.thumbnail, .thumb, [class*="thumb"], #ap_thumbs, .gp_th')) {
                                console.log('[VS Debug] MutationObserver detected thumbs');
                                setTimeout(() => vs.init(), 100);
                                return;
                            }
                        }
                    }
                }
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });
        console.log('[VS Debug] MutationObserver started');

        const pollInit = setInterval(() => {
            tryInit();
            initAttempts++;
            console.log('[VS Debug] Poll attempt', initAttempts, '/', maxInitAttempts);
            if (vs.initialized || initAttempts >= maxInitAttempts) {
                clearInterval(pollInit);
                console.log('[VS Debug] Polling stopped, initialized:', vs.initialized);
            }
        }, 500);

        tryInit();
    }

    let lastPathname = window.location.pathname;
    setInterval(() => {
        if (window.location.pathname !== lastPathname) {
            console.log('[VS Debug] URL changed from', lastPathname, 'to', window.location.pathname);
            lastPathname = window.location.pathname;
            if (window.location.pathname.match(/^\/gallery\/\d+/)) {
                setTimeout(() => {
                    const styles = document.querySelector('#vs-styles');
                    if (styles) styles.remove();
                    const nav = document.querySelector('.vs-nav');
                    if (nav) nav.remove();
                    const navTop = document.querySelector('.vs-nav-top');
                    if (navTop) navTop.remove();
                    const loading = document.querySelector('#vs-loading');
                    if (loading) loading.remove();
                    const downloadWrap = document.querySelector('#vs-download-wrap');
                    if (downloadWrap) downloadWrap.remove();
                    initScript();
                }, 500);
            }
        }
    }, 1000);

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initScript);
    } else {
        initScript();
    }
})();