Hentaizap Vertical Scroll

View hentaizap galleries in a vertical scroll instead of clicking page by page

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Hentaizap Vertical Scroll
// @namespace    https://hentaizap.com
// @version      3.3
// @author       JoyArz
// @description  View hentaizap galleries in a vertical scroll instead of clicking page by page
// @match        https://hentaizap.com/gallery/*/
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const DEBUG = false;
    const log = (...args) => DEBUG && console.log('[VS Debug]', ...args);

    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: 20px; left: 20px; background: rgba(0, 0, 0, 0.8); color: white; padding: 8px 16px; border-radius: 20px; font-family: sans-serif; font-size: 12px; z-index: 9999; text-align: center; }
        .vs-nav { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 9998; }
        .vs-nav button { width: 36px; height: 36px; border-radius: 50%; border: none; background: #ff3366; color: white; font-size: 16px; cursor: pointer; opacity: 0.8; transition: opacity 0.2s, transform 0.2s; }
        .vs-nav button:hover { opacity: 1; transform: scale(1.1); }
        .vs-nav-top { position: fixed; top: 20px; right: 20px; z-index: 9998; }
        .vs-nav-top button { padding: 4px 8px; border-radius: 12px; border: none; background: #ff3366; color: white; font-size: 11px; cursor: pointer; opacity: 0.8; }
        .vs-nav-top button:hover { opacity: 1; }
        .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">
            <button id="vs-toggle-zoom">Zoom: 0.62x</button>
            <button id="vs-show-failed"></button>
        </div>
    `;

    class HentaizapScroll {
        constructor() {
            this.galleryId = null;
            this.pages = 0;
            this.currentZoom = 0.62;
            this.container = null;
            this.loadedPages = new Set();
            this.failedPages = new Set();
            this.queuedPages = 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;

            this._normalizeTimer = null;
            this.pageObserver = null;
        }

        extractGalleryInfo() {
            log('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);
                }
            }

            log('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];
        }

        init() {
            log('init called');
            if (this.initialized) return;
            this.initialized = true;

            this.extractGalleryInfo();

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

            this.injectStyles();
            this.createVerticalView();
            this.addNavigation();
            this.setupObserver();
            this.loadInitialBatch();
        }

        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() {
            const styleEl = document.createElement('style');
            styleEl.id = 'vs-styles';
            styleEl.textContent = STYLE;
            document.head.appendChild(styleEl);
        }

        createVerticalView() {
            log('createVerticalView called, pages:', this.pages);

            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);
            }

            // Replace #ap_thumbs in-place so the reader sits right after the
            // info/action-button area, not appended to the bottom of the page.
            const apThumbs = document.getElementById('ap_thumbs');
            if (apThumbs) {
                apThumbs.replaceWith(this.container);
            } else {
                // Fallback: hide any stray thumbnail elements, then append to content
                document.querySelectorAll('.thumbnail, .thumb, [class*="thumb"], #thumbnail-container')
                    .forEach(el => el.classList.add('hidden'));
                const mainContent = document.querySelector('#content, main, .content, [role="main"]');
                (mainContent || document.body).appendChild(this.container);
            }

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

            log('Container inserted');
        }

        addNavigation() {
            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-toggle-zoom').addEventListener('click', (e) => {
                this.toggleZoom(e.target);
            });
            document.getElementById('vs-retry-failed').addEventListener('click', () => {
                this.retryFailedPages();
            });

            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() {
            log('retryFailedPages called, failed count:', this.failedPages.size);
            if (this.failedPages.size === 0) return;

            const failedEls = Array.from(this.container.querySelectorAll('.vs-page-failed'));
            this.failedPages.clear();

            failedEls.forEach(pageEl => {
                const pageNum = parseInt(pageEl.dataset.page, 10);
                pageEl.classList.remove('vs-page-failed');
                const img = pageEl.querySelector('img');
                img.style.opacity = '';
                img.alt = `Page ${pageNum}`;
                this.loadedPages.delete(pageNum);
                this.queuedPages.delete(pageNum);
                this.loadedCount--;
                this.loadImageWithRetry(pageEl, null, () => {});
            });

            this.updateFailedButton();
        }

        toggleZoom(btn) {
            if (this.currentZoom === 0.62) this.currentZoom = 1.0;
            else if (this.currentZoom === 1.0) this.currentZoom = 1.5;
            else this.currentZoom = 0.62;
            this.normalizeWidths();
            if (btn) btn.textContent = `Zoom: ${this.currentZoom}x`;
        }

        setupObserver() {
            if (!('IntersectionObserver' in window)) {
                window.addEventListener('scroll', () => this.loadVisiblePagesFallback(), { passive: true });
                return;
            }

            this.pageObserver = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) this.loadNearbyPages(entry.target);
                });
            }, { rootMargin: '300px 0px' });

            this.container.querySelectorAll('.vs-page').forEach(page => {
                this.pageObserver.observe(page);
            });
        }

        loadNearbyPages(pageEl) {
            const pageNum = parseInt(pageEl.dataset.page, 10);
            for (let i = pageNum; i <= Math.min(pageNum + 3, this.pages); i++) {
                if (this.loadedPages.has(i) || this.queuedPages.has(i)) continue;
                const el = this.container.querySelector(`[data-page="${i}"]`);
                if (!el) continue;
                this.queuedPages.add(i);
                this.loadImageWithRetry(el, document.getElementById('vs-loading'), () => {});
            }
        }

        loadVisiblePagesFallback() {
            if (!this.container) return;
            const currentPage = Math.floor((window.scrollY + window.innerHeight) / window.innerHeight);
            this.container.querySelectorAll('.vs-page').forEach(pageEl => {
                const pageNum = parseInt(pageEl.dataset.page, 10);
                if (this.loadedPages.has(pageNum) || this.queuedPages.has(pageNum)) return;
                if (pageNum <= currentPage + 3) {
                    this.queuedPages.add(pageNum);
                    this.loadImageWithRetry(pageEl, null, () => {});
                }
            });
        }

        loadInitialBatch() {
            log('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));

            toLoad.forEach(pageEl => {
                this.queuedPages.add(parseInt(pageEl.dataset.page, 10));
            });

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

        loadBatch(pageEls, loading, onComplete) {
            const total = pageEls.length;
            if (total === 0) { if (onComplete) onComplete(); return; }
            let loaded = 0;
            pageEls.forEach(pageEl => {
                this.loadImageWithRetry(pageEl, loading, () => {
                    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;
            log('loadImageWithRetry page', pageNum);

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

                img.onload = () => {
                    img.classList.remove('loading');
                    this.loadedPages.add(pageNum);
                    this.loadedCount++;
                    if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
                    log('Page', pageNum, 'loaded');
                    this.normalizeWidths();
                    this.checkAllLoaded();
                    onDone();
                };

                img.onerror = () => {
                    if (retryCount < this.maxRetries) {
                        retryCount++;
                        setTimeout(() => tryLoad(urlIndex), 300 * retryCount);
                    } else {
                        retryCount = 0;
                        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() {
            log('checkAllLoaded:', this.loadedPages.size, '/', this.pages);
            if (this.loadedPages.size >= this.pages) {
                const loading = document.getElementById('vs-loading');
                if (loading) loading.classList.add('hidden');
                this.normalizeWidths();
            }
        }

        normalizeWidths() {
            if (this._normalizeTimer) clearTimeout(this._normalizeTimer);
            this._normalizeTimer = setTimeout(() => {
                if (!this.container) return;
                const baseWidth = 450;
                const targetWidth = Math.min(
                    Math.round(baseWidth * this.currentZoom / 0.62),
                    window.innerWidth
                );
                this.container.querySelectorAll('.vs-page img').forEach(img => {
                    img.classList.add('uniform-width');
                    img.style.width = targetWidth + 'px';
                    img.style.maxWidth = targetWidth + 'px';
                });
            }, 100);
        }

        loadRemainingBatches() {
            log('loadRemainingBatches called, batchStart:', this.currentBatchStart);
            if (this.isLoadingBatches || this.currentBatchStart >= this.pages) {
                if (this.currentBatchStart >= this.pages) 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);

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

            nextBatch.forEach(pageEl => {
                this.queuedPages.add(parseInt(pageEl.dataset.page, 10));
            });

            this.loadBatch(nextBatch, null, () => {
                this.currentBatchStart += nextBatch.length;
                this.isLoadingBatches = false;
                this.checkAllLoaded();
                if (this.currentBatchStart < this.pages) {
                    setTimeout(() => this.loadRemainingBatches(), 100);
                }
            });
        }
    }

    function initScript() {
        log('initScript called, pathname:', window.location.pathname);
        if (!window.location.pathname.match(/^\/gallery\/\d+/)) return;

        // Clean up any previous instance
        ['#vs-styles', '.vs-nav', '.vs-nav-top', '#vs-loading'].forEach(sel => {
            document.querySelector(sel)?.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');
            if ((content || hasThumbs) && !vs.initialized) vs.init();
        };

        const mutationObserver = new MutationObserver((mutations) => {
            if (vs.initialized) return;
            for (const mutation of mutations) {
                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')) {
                            setTimeout(() => vs.init(), 100);
                            return;
                        }
                    }
                }
            }
        });

        mutationObserver.observe(document.body, { childList: true, subtree: true });

        const pollInit = setInterval(() => {
            tryInit();
            if (vs.initialized || ++initAttempts >= maxInitAttempts) {
                clearInterval(pollInit);
                mutationObserver.disconnect();
            }
        }, 500);

        tryInit();
    }

    // SPA navigation detection
    let lastPathname = window.location.pathname;
    setInterval(() => {
        if (window.location.pathname !== lastPathname) {
            lastPathname = window.location.pathname;
            if (window.location.pathname.match(/^\/gallery\/\d+/)) {
                setTimeout(() => initScript(), 500);
            }
        }
    }, 1000);

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