☰

Hentaizap Vertical Scroll

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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();
    }
})();