☰

Hentaizap Vertical Scroll

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Hentaizap Vertical Scroll
// @namespace    https://hentaizap.com
// @version      5.2
// @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: none; 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; }
        .vs-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;
        }
 
        async extractGalleryInfo() {
            this.galleryId = location.pathname.match(/^\/gallery\/(\d+)(?:\/|$)/)?.[1];
            if (!this.galleryId) return;
            const grid = document.getElementById('js-thumbs-grid');
            if (grid) {
                // The current site publishes authoritative per-page extensions
                // in its reader document, not in the thumbnail filenames.
                const link = grid.querySelector('a[href]');
                if (!link) throw new Error('Reader link is not ready');
                const readerUrl = new URL(link.getAttribute('href'), location.href);
                if (readerUrl.origin !== location.origin ||
                    !readerUrl.pathname.startsWith(`/g/${this.galleryId}/`)) {
                    throw new Error('Unexpected reader link');
                }
                const controller = new AbortController();
                const timeout = setTimeout(() => controller.abort(), 20000);
                try {
                    const response = await fetch(readerUrl.href, {
                        credentials: 'same-origin', signal: controller.signal
                    });
                    if (!response.ok) throw new Error(`Reader returned HTTP ${response.status}`);
                    const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
                    this.readReaderMetadata(doc);
                } finally {
                    clearTimeout(timeout);
                }
                return;
            }
            // Compatibility with the older gallery layout.
            this.hentaizapServer = document.getElementById('load_server')?.value;
            this.hentaizapDir = document.getElementById('load_dir')?.value;
            this.hentaizapHash = document.getElementById('load_id')?.value;
            this.pages = Number(document.getElementById('load_pages')?.value) || 0;
        }

        readReaderMetadata(doc) {
            const app = doc.getElementById('readerApp');
            const json = doc.getElementById('readerPagesJson');
            if (!app || !json || app.dataset.readerId !== this.galleryId) {
                throw new Error('Reader metadata unavailable; original gallery preserved');
            }
            if (app.dataset.readerLocked === '1') throw new Error('Reader is locked');
            const total = Number(app.dataset.readerTotal);
            const base = new URL(app.dataset.readerImageBase);
            if (base.protocol !== 'https:' || !/^m\d+\.hentaizap\.com$/.test(base.hostname) ||
                base.username || base.password || base.port || base.search || base.hash) {
                throw new Error('Unexpected image host');
            }
            const entries = JSON.parse(json.textContent);
            if (!Number.isSafeInteger(total) || total < 1 || !Array.isArray(entries) || entries.length !== total) {
                throw new Error('Incomplete reader page list');
            }
            const pages = new Map();
            for (const entry of entries) {
                const page = Number(entry.page);
                const ext = String(entry.ext).toLowerCase().replace(/^\./, '');
                if (!Number.isInteger(page) || page < 1 || page > total || pages.has(page) ||
                    !/^(webp|jpg|jpeg|png|gif|avif)$/.test(ext)) {
                    throw new Error('Invalid reader page metadata');
                }
                pages.set(page, {
                    url: `${base.href.replace(/\/$/, '')}/${page}.${ext}`,
                    width: Number(entry.width), height: Number(entry.height)
                });
            }
            this.pageMetadata = pages;
            this.pages = total;
        }

        getImageUrls(pageNum) {
            const known = this.pageMetadata?.get(pageNum);
            if (known) return [known.url];
            if (!this.hentaizapServer || !this.hentaizapDir || !this.hentaizapHash) return [];
            const base = `https://m${this.hentaizapServer}.hentaizap.com/${this.hentaizapDir}/${this.hentaizapHash}/${pageNum}`;
            return ['webp', 'jpg', 'jpeg', 'png'].map(ext => `${base}.${ext}`);
        }

        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 (Number.isFinite(parsed)) return Math.min(400, Math.max(10, 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 = Math.min(400, Math.max(10, percent));
            this.saveWidth(this.widthPercent);
            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 vs-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('vs-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 = 'Still waiting for image responses...';
                }
            }, 8000);
 
            const request = url => new Promise(resolve => {
                let settled = false;
                let handle;
                const finish = response => {
                    if (settled) return;
                    settled = true;
                    clearTimeout(timer);
                    responseReceived = true;
                    resolve(response);
                };
                const timer = setTimeout(() => {
                    finish(null);
                    try { handle?.abort(); } catch (_) { /* Already settled. */ }
                }, 22000);
                try {
                    handle = GM_xmlhttpRequest({
                        method: 'GET', url, responseType: 'arraybuffer', timeout: 20000,
                        onload: finish, onerror: () => finish(null),
                        ontimeout: () => finish(null), onabort: () => finish(null)
                    });
                } catch (error) {
                    console.warn('[VS] Request failed:', error);
                    finish(null);
                }
            });
            const fetchPage = async pageNum => {
                for (const url of this.getImageUrls(pageNum)) {
                    const res = await request(url);
                    if (res && res.status >= 200 && res.status < 300 && res.response?.byteLength) {
                        const bytes = new Uint8Array(res.response);
                        // Reject HTML challenge/error pages returned with HTTP 200.
                        const image = (bytes[0] === 0xff && bytes[1] === 0xd8) ||
                            (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) ||
                            (String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF' &&
                             String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP');
                        const gif = String.fromCharCode(...bytes.slice(0, 6)).match(/^GIF8[79]a$/);
                        const avif = String.fromCharCode(...bytes.slice(4, 12)).match(/^ftypavi[fs]$/);
                        if (!image && !gif && !avif) continue;
                        const ext = bytes[0] === 0xff ? 'jpg' : bytes[0] === 0x89 ? 'png' : gif ? 'gif' : avif ? 'avif' : 'webp';
                        zipFiles.push({ name: `${String(pageNum).padStart(String(total).length, '0')}.${ext}`, data: res.response });
                        done++;
                        updateProgress();
                        return;
                    }
                }
                failed.push(pageNum);
                done++;
                updateProgress();
            };

            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');
                if (!zipFiles.length) throw new Error('No images downloaded');
                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), 60000);
                console.log('[VS Debug] Zip download triggered:', filename);
            } catch (e) {
                console.log('[VS Debug] Zip generation failed:', e);
                if (progressText) progressText.textContent = 'ZIP failed: ' + e.message;
                btn.disabled = false;
                this.isDownloadingZip = false;
                return;
            }
 
            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('vs-hidden');
            }, 3000);
        }
 
        async init() {
            console.log('[VS Debug] init called');
            if (this.initialized) return;
            await this.extractGalleryInfo();
 
            if (!this.galleryId || !Number.isSafeInteger(this.pages) || this.pages < 1 || !this.getImageUrls(1).length) {
                console.log('[VS Debug] Gallery info missing');
                return;
            }
 
            this.initialized = true;
            this.injectStyles();
            this.createVerticalView();
            this.addDownloadButton();
            this.addNavigation();

        }
 
        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('#js-thumbs-grid, #thumbnail-container');
            const hentaizapThumbs = document.querySelectorAll('.thumbnail, .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;
                const img = document.createElement('img');
                img.className = 'loading';
                img.alt = `Page ${i}`;
                img.decoding = 'async';
                const meta = this.pageMetadata?.get(i);
                if (meta?.width > 0 && meta?.height > 0) {
                    img.width = meta.width;
                    img.height = meta.height;
                }
                pageEl.appendChild(img);
                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('vs-hidden');
            if (!thumbnailContainer) hentaizapThumbs.forEach(el => el.classList.add('vs-hidden'));
            document.getElementById('js-thumbs-actions')?.classList.add('vs-hidden');
 
            const loading = document.createElement('div');
            loading.className = 'vs-loading';
            loading.id = 'vs-loading';
            loading.textContent = `Loading...`;
            document.body.appendChild(loading);
 
            this.normalizeWidths();

            // 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);
                    widthInput.value = this.widthPercent;
                } 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();
                }
            });
 
            document.getElementById('vs-show-failed').addEventListener('click', () => {
                this.container.querySelector('.vs-page-failed')?.scrollIntoView({ behavior: 'smooth' });
            });
            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'));
            
            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();
        }
 
        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 pageNum = Number(pageEl.dataset.page);
            if (this.loadedPages.has(pageNum)) { onDone(); return; }
            // Share completion rather than replacing an in-flight image's handlers.
            if (pageEl.vsPending) { pageEl.vsPending.then(onDone); return; }
            const img = pageEl.querySelector('img');
            const urls = this.getImageUrls(pageNum);
            this.failedPages.delete(pageNum);
            pageEl.classList.remove('vs-page-failed');
            img.classList.add('loading');
            img.style.opacity = '';
            let finish;
            pageEl.vsPending = new Promise(resolve => { finish = resolve; });
            pageEl.vsPending.then(onDone);
            const complete = success => {
                img.onload = img.onerror = null;
                img.classList.remove('loading');
                if (success) {
                    this.loadedPages.add(pageNum);
                    img.alt = `Page ${pageNum}`;
                } else {
                    this.failedPages.add(pageNum);
                    pageEl.classList.add('vs-page-failed');
                    img.alt = `Failed page ${pageNum}`;
                }
                this.loadedCount = this.loadedPages.size;
                pageEl.vsPending = null;
                this.normalizeWidths();
                this.checkAllLoaded();
                finish();
            };
            const attempt = (index, retry = 0) => {
                if (index >= urls.length) { complete(false); return; }
                let settled = false;
                let timer;
                const settle = success => {
                    if (settled) return;
                    settled = true;
                    clearTimeout(timer);
                    img.onload = img.onerror = null;
                    if (success) { complete(true); return; }
                    img.removeAttribute('src');
                    setTimeout(() => retry < this.maxRetries
                        ? attempt(index, retry + 1) : attempt(index + 1), 300);
                };
                img.onload = () => settle(img.naturalWidth > 0);
                img.onerror = () => settle(false);
                timer = setTimeout(() => settle(false), 20000);
                img.src = urls[index];
            };
            this.checkAllLoaded();
            attempt(0);
        }

        checkAllLoaded() {
            const totalProcessed = this.loadedPages.size + this.failedPages.size;
            const loading = document.getElementById('vs-loading');
            if (loading) {
                loading.textContent = `Loaded ${this.loadedPages.size}/${this.pages}` +
                    (this.failedPages.size ? ` · Failed ${this.failedPages.size}` : '');
                loading.classList.toggle('vs-hidden', totalProcessed >= this.pages);
            }
            this.updateFailedButton();
        }

        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 = '100%';
                    img.style.maxWidth = '100%';
                } 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);
                }
            });
        }
 

    }
 
    function initScript() {
        if (!/^\/gallery\/\d+(?:\/|$)/.test(location.pathname)) return;
        const vs = new HentaizapScroll();
        let attempts = 0;
        let busy = false;
        const tryInit = async () => {
            if (busy || vs.initialized) return;
            busy = true;
            try {
                await vs.init();
            } catch (error) {
                console.warn('[VS] Initialization:', error);
            } finally {
                busy = false;
                if (vs.initialized || ++attempts >= 3) {
                    clearInterval(poll);
                    if (!vs.initialized) {
                        const notice = document.createElement('div');
                        notice.textContent = 'Vertical Scroll: could not read the gallery metadata. The original gallery is still available. Reload to retry.';
                        notice.style.cssText = 'padding:12px;background:#29202a;color:white';
                        document.querySelector('main')?.prepend(notice);
                    }
                }
            }
        };
        const poll = setInterval(tryInit, 2000);
        tryInit();
    }

    // A full document refresh on same-document gallery navigation prevents stale
    // callbacks, containers and event listeners from a previous gallery.
    const initialPath = location.pathname;
    setInterval(() => {
        if (location.pathname !== initialPath) location.reload();
    }, 1000);
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initScript, { once: true });
    } else {
        initScript();
    }
})();