nHentai - Vertical Cascade Reader

Adds a continuous vertical reader with progressive loading, zoom controls, page counter and automatic retries.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         nHentai - Vertical Cascade Reader
// @namespace    jack.nhentai.vertical.reader
// @version      1.3.0
// @description  Adds a continuous vertical reader with progressive loading, zoom controls, page counter and automatic retries.
// @match        https://nhentai.net/g/*
// @match        https://www.nhentai.net/g/*
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const APP_ID = 'jack-nh-reader';
    const BUTTON_ID = 'jack-nh-open-reader';
    const STYLE_ID = 'jack-nh-reader-style';

    let readerWidth = 900;
    let oldBodyOverflow = '';
    let fallbackQueue = Promise.resolve();
    let lastRouteSignature = '';
    let autoOpenTimer = null;


    // ============================================================
    // LEER SIEMPRE LA URL ACTUAL
    // ============================================================

    function getRoute() {

        const match =
            location.pathname.match(
                /^\/g\/(\d+)(?:\/(\d+))?\/?$/i
            );

        if (!match) {
            return null;
        }

        return {

            galleryId:
                match[1],

            currentPage:
                Math.max(
                    1,
                    Number(match[2] || 1)
                ),

            isReaderPage:
                Boolean(match[2]),

            signature:
                `${match[1]}:${match[2] || 'cover'}`
        };
    }


    // ============================================================
    // ESTILOS
    // ============================================================

    function addStyles() {

        if (
            document.getElementById(
                STYLE_ID
            )
        ) {
            return;
        }


        const style =
            document.createElement(
                'style'
            );

        style.id =
            STYLE_ID;


        style.textContent = `

            #${BUTTON_ID} {

                position:
                    fixed !important;

                right:
                    22px !important;

                bottom:
                    22px !important;

                z-index:
                    2147483647 !important;

                padding:
                    12px 17px !important;

                border:
                    1px solid
                    rgba(255,255,255,.18)
                    !important;

                border-radius:
                    10px !important;

                background:
                    rgba(25,25,25,.94)
                    !important;

                color:
                    white !important;

                font-family:
                    Arial,
                    sans-serif !important;

                font-size:
                    14px !important;

                font-weight:
                    600 !important;

                cursor:
                    pointer !important;

                box-shadow:
                    0 5px 20px
                    rgba(0,0,0,.4)
                    !important;
            }


            #${BUTTON_ID}:hover {

                background:
                    rgba(50,50,50,.98)
                    !important;
            }


            #${APP_ID} {

                --jack-width:
                    ${readerWidth}px;

                position:
                    fixed !important;

                inset:
                    0 !important;

                z-index:
                    2147483646 !important;

                overflow-y:
                    auto !important;

                overflow-x:
                    hidden !important;

                background:
                    #111 !important;

                overscroll-behavior:
                    contain !important;
            }


            #${APP_ID} .jack-pages {

                width:
                    100% !important;

                display:
                    flex !important;

                flex-direction:
                    column !important;

                align-items:
                    center !important;

                margin:
                    0 !important;

                padding:
                    0 !important;
            }


            #${APP_ID} .jack-page {

                width:
                    100% !important;

                display:
                    flex !important;

                justify-content:
                    center !important;

                align-items:
                    flex-start !important;

                margin:
                    0 !important;

                padding:
                    0 !important;

                min-height:
                    700px !important;

                background:
                    #111 !important;

                position:
                    relative !important;
            }


            #${APP_ID} .jack-page.jack-loaded {

                min-height:
                    0 !important;
            }


            #${APP_ID} .jack-page img {

                display:
                    block !important;

                width:
                    min(
                        var(--jack-width),
                        100vw
                    )
                    !important;

                max-width:
                    100vw !important;

                height:
                    auto !important;

                margin:
                    0 auto !important;

                padding:
                    0 !important;

                border:
                    0 !important;

                object-fit:
                    contain !important;

                background:
                    #111 !important;
            }


            #${APP_ID} .jack-loader {

                position:
                    absolute !important;

                left:
                    50% !important;

                top:
                    50% !important;

                transform:
                    translate(-50%, -50%)
                    !important;

                color:
                    #aaa !important;

                font:
                    14px Arial,
                    sans-serif !important;

                text-align:
                    center !important;

                line-height:
                    1.5 !important;
            }


            #${APP_ID} .jack-retry {

                margin-top:
                    10px !important;

                padding:
                    8px 14px !important;

                border:
                    1px solid #555
                    !important;

                border-radius:
                    7px !important;

                background:
                    #222 !important;

                color:
                    white !important;

                cursor:
                    pointer !important;

                font:
                    13px Arial,
                    sans-serif !important;
            }


            #${APP_ID} .jack-retry:hover {

                background:
                    #333 !important;
            }


            #${APP_ID} .jack-toolbar {

                position:
                    fixed !important;

                right:
                    18px !important;

                top:
                    50% !important;

                transform:
                    translateY(-50%)
                    !important;

                width:
                    145px !important;

                overflow:
                    hidden !important;

                background:
                    rgba(20,20,20,.72)
                    !important;

                backdrop-filter:
                    blur(10px)
                    !important;

                -webkit-backdrop-filter:
                    blur(10px)
                    !important;

                border:
                    1px solid
                    rgba(255,255,255,.14)
                    !important;

                border-radius:
                    12px !important;

                box-shadow:
                    0 6px 24px
                    rgba(0,0,0,.4)
                    !important;

                z-index:
                    2147483647 !important;

                font-family:
                    Arial,
                    sans-serif !important;

                opacity:
                    .72 !important;

                transition:
                    opacity .15s ease,
                    background .15s ease
                    !important;
            }


            #${APP_ID} .jack-toolbar:hover {

                opacity:
                    1 !important;

                background:
                    rgba(20,20,20,.96)
                    !important;
            }


            #${APP_ID} .jack-toolbar button,
            #${APP_ID} .jack-toolbar a,
            #${APP_ID} .jack-counter {

                width:
                    100% !important;

                height:
                    46px !important;

                box-sizing:
                    border-box !important;

                border:
                    0 !important;

                border-bottom:
                    1px solid
                    rgba(255,255,255,.08)
                    !important;

                background:
                    transparent !important;

                color:
                    white !important;

                font:
                    500 13px Arial,
                    sans-serif !important;
            }


            #${APP_ID} .jack-toolbar button,
            #${APP_ID} .jack-toolbar a {

                display:
                    flex !important;

                align-items:
                    center !important;

                justify-content:
                    center !important;

                text-decoration:
                    none !important;

                cursor:
                    pointer !important;
            }


            #${APP_ID} .jack-toolbar button:hover,
            #${APP_ID} .jack-toolbar a:hover {

                background:
                    rgba(255,255,255,.13)
                    !important;
            }


            #${APP_ID} .jack-counter {

                display:
                    flex !important;

                align-items:
                    center !important;

                justify-content:
                    center !important;
            }


            #${APP_ID} .jack-start-message {

                width:
                    100% !important;

                height:
                    100vh !important;

                display:
                    flex !important;

                justify-content:
                    center !important;

                align-items:
                    center !important;

                color:
                    white !important;

                font:
                    15px Arial,
                    sans-serif !important;

                text-align:
                    center !important;
            }

        `;


        document.head
            .appendChild(
                style
            );
    }


    // ============================================================
    // OBTENER URL DE IMAGEN
    // ============================================================

    function getImageSource(img) {

        if (!img) {
            return null;
        }


        const candidates = [

            img.getAttribute(
                'data-src'
            ),

            img.getAttribute(
                'data-original'
            ),

            img.getAttribute(
                'data-lazy-src'
            ),

            img.getAttribute(
                'src'
            )
        ];


        for (
            const value
            of
            candidates
        ) {

            if (
                !value ||
                value.startsWith(
                    'data:'
                )
            ) {
                continue;
            }


            try {

                return new URL(
                    value,
                    location.origin
                ).href;

            } catch (_) {}
        }


        const srcset =

            img.getAttribute(
                'srcset'
            )

            ||

            img.getAttribute(
                'data-srcset'
            );


        if (srcset) {

            const first =
                srcset
                    .split(',')[0]
                    ?.trim()
                    .split(/\s+/)[0];


            if (first) {

                try {

                    return new URL(
                        first,
                        location.origin
                    ).href;

                } catch (_) {}
            }
        }


        return null;
    }


    // ============================================================
    // MINIATURA → IMAGEN COMPLETA
    // ============================================================

    function thumbnailToFullImages(
        src
    ) {

        if (!src) {
            return [];
        }


        try {

            const url =
                new URL(
                    src,
                    location.origin
                );


            url.hostname =
                url.hostname.replace(
                    /^t(\d*)\./i,
                    'i$1.'
                );


            const pageMatch =
                url.pathname.match(
                    /^(.*\/)(\d+)t\.([a-z0-9]+)$/i
                );


            if (!pageMatch) {

                return [

                    url.href.replace(
                        /t(\.[a-z0-9]+)(?:\?.*)?$/i,
                        '$1'
                    )
                ];
            }


            const folder =
                pageMatch[1];


            const pageNumber =
                pageMatch[2];


            const originalExtension =
                pageMatch[3]
                    .toLowerCase();


            const extensions =
                [
                    ...new Set(
                        [
                            originalExtension,
                            'jpg',
                            'jpeg',
                            'png',
                            'webp',
                            'gif',
                            'avif'
                        ]
                    )
                ];


            return extensions.map(
                extension => {

                    const candidate =
                        new URL(
                            url.href
                        );


                    candidate.pathname =
                        `${folder}${pageNumber}.${extension}`;


                    candidate.search =
                        '';


                    return candidate.href;
                }
            );


        } catch (_) {

            return [];
        }
    }


    // ============================================================
    // VARIANTES DE EXTENSIÓN
    // ============================================================

    function createImageVariants(
        src
    ) {

        if (!src) {
            return [];
        }


        try {

            const url =
                new URL(
                    src,
                    location.origin
                );


            const match =
                url.pathname.match(
                    /^(.*\/)(\d+)\.([a-z0-9]+)$/i
                );


            if (!match) {

                return [
                    url.href
                ];
            }


            const folder =
                match[1];


            const number =
                match[2];


            const originalExtension =
                match[3]
                    .toLowerCase();


            const extensions =
                [
                    ...new Set(
                        [
                            originalExtension,
                            'jpg',
                            'jpeg',
                            'png',
                            'webp',
                            'gif',
                            'avif'
                        ]
                    )
                ];


            return extensions.map(
                extension => {

                    const candidate =
                        new URL(
                            url.href
                        );


                    candidate.pathname =
                        `${folder}${number}.${extension}`;


                    candidate.search =
                        '';


                    return candidate.href;
                }
            );


        } catch (_) {

            return [
                src
            ];
        }
    }


    // ============================================================
    // EXTRAER PÁGINAS DE UNA GALERÍA
    // ============================================================

    function extractPagesFromDocument(
        doc,
        galleryId
    ) {

        const result =
            new Map();


        for (
            const link
            of
            doc.querySelectorAll(
                'a[href]'
            )
        ) {

            let url;


            try {

                url =
                    new URL(
                        link.getAttribute(
                            'href'
                        ),
                        location.origin
                    );

            } catch (_) {

                continue;
            }


            const match =
                url.pathname.match(

                    new RegExp(
                        `^/g/${galleryId}/(\\d+)/?$`,
                        'i'
                    )
                );


            if (!match) {
                continue;
            }


            const pageNumber =
                Number(
                    match[1]
                );


            if (
                !Number.isFinite(
                    pageNumber
                )

                ||

                pageNumber < 1
            ) {

                continue;
            }


            const thumb =
                link.querySelector(
                    'img'
                );


            const thumbSrc =
                getImageSource(
                    thumb
                );


            const possibleImages =
                thumbnailToFullImages(
                    thumbSrc
                );


            result.set(

                pageNumber,

                {

                    number:
                        pageNumber,

                    galleryId:
                        galleryId,

                    pageUrl:
                        `${location.origin}/g/${galleryId}/${pageNumber}/`,

                    thumbUrl:
                        thumbSrc,

                    imageUrl:
                        possibleImages[0] ||
                        null,

                    autoRetries:
                        0
                }
            );
        }


        return result;
    }


    // ============================================================
    // DETECTAR TOTAL DE PÁGINAS
    // ============================================================

    function findTotalPages(
        doc
    ) {

        const text =
            doc.body?.innerText ||
            '';


        const patterns = [

            /Pages?\s*:?\s*(\d+)/i,

            /(\d+)\s+pages?/i,

            /(\d+)\s+páginas?/i
        ];


        for (
            const regex
            of
            patterns
        ) {

            const found =
                text.match(
                    regex
                );


            if (!found) {
                continue;
            }


            const number =
                Number(
                    found[1]
                );


            if (
                Number.isFinite(
                    number
                )

                &&

                number > 0

                &&

                number < 10000
            ) {

                return number;
            }
        }


        return 0;
    }


    // ============================================================
    // CARGAR SIEMPRE LA GALERÍA ACTUAL
    // ============================================================

    async function getGalleryPages(
        galleryId
    ) {

        /*
         * IMPORTANTE:
         *
         * Se fuerza una petición NUEVA usando
         * exclusivamente la ID actual.
         *
         * No se reutiliza la lista anterior.
         */

        const response =
            await fetch(

                `/g/${galleryId}/?_cascade=${Date.now()}`,

                {

                    credentials:
                        'include',

                    cache:
                        'no-store'
                }
            );


        if (!response.ok) {

            throw new Error(

                `No pude abrir la galería ${galleryId}. HTTP ${response.status}`
            );
        }


        const html =
            await response.text();


        const doc =
            new DOMParser()
                .parseFromString(
                    html,
                    'text/html'
                );


        const pageMap =
            extractPagesFromDocument(
                doc,
                galleryId
            );


        const total =
            findTotalPages(
                doc
            );


        let highestDetected =
            0;


        for (
            const number
            of
            pageMap.keys()
        ) {

            highestDetected =
                Math.max(

                    highestDetected,

                    number
                );
        }


        /*
         * Si la ficha dice 51 páginas,
         * pero solo encontramos miniaturas
         * de algunas, igualmente generamos
         * las 51 posiciones.
         */

        const finalTotal =
            Math.max(

                total,

                highestDetected,

                pageMap.size
            );


        if (!finalTotal) {

            throw new Error(

                `No pude detectar las páginas de la galería ${galleryId}.`
            );
        }


        const pages =
            [];


        for (
            let i = 1;
            i <= finalTotal;
            i++
        ) {

            const detected =
                pageMap.get(
                    i
                );


            pages.push(

                detected

                ||

                {

                    number:
                        i,

                    galleryId:
                        galleryId,

                    pageUrl:
                        `${location.origin}/g/${galleryId}/${i}/`,

                    thumbUrl:
                        null,

                    imageUrl:
                        null,

                    autoRetries:
                        0
                }
            );
        }


        return pages;
    }


    // ============================================================
    // OBTENER IMAGEN DESDE PÁGINA INDIVIDUAL
    // ============================================================

    async function getImageFromReaderPage(
        page
    ) {

        const response =
            await fetch(

                `${page.pageUrl}?_cascade=${Date.now()}`,

                {

                    credentials:
                        'include',

                    cache:
                        'no-store'
                }
            );


        if (!response.ok) {

            throw new Error(
                `HTTP ${response.status}`
            );
        }


        const html =
            await response.text();


        const doc =
            new DOMParser()
                .parseFromString(
                    html,
                    'text/html'
                );


        const selectors = [

            '#image-container img',

            'section#image-container img',

            '.image-container img',

            'img[src*="/galleries/"]',

            'img[data-src*="/galleries/"]'
        ];


        for (
            const selector
            of
            selectors
        ) {

            for (
                const img
                of
                doc.querySelectorAll(
                    selector
                )
            ) {

                const src =
                    getImageSource(
                        img
                    );


                if (!src) {
                    continue;
                }


                try {

                    const parsed =
                        new URL(
                            src
                        );


                    if (
                        /^i\d*\./i
                            .test(
                                parsed.hostname
                            )
                    ) {

                        return src;
                    }

                } catch (_) {}


                const converted =
                    thumbnailToFullImages(
                        src
                    );


                if (
                    converted.length
                ) {

                    return converted[0];
                }
            }
        }


        const matches =
            html.match(

                /https?:\\?\/\\?\/i\d*\.nhentai\.net\\?\/galleries\\?\/[^"'<> ]+\.(?:jpg|jpeg|png|gif|webp|avif)/ig
            );


        if (
            matches?.length
        ) {

            return matches[0]

                .replace(
                    /\\u002F/gi,
                    '/'
                )

                .replace(
                    /\\\//g,
                    '/'
                );
        }


        throw new Error(

            `No encontré la imagen de la página ${page.number}`
        );
    }


    // ============================================================
    // COLA DE FALLBACK
    // ============================================================

    function getImageFromReaderPageQueued(
        page
    ) {

        const job =
            fallbackQueue.then(
                async () => {

                    await new Promise(
                        resolve =>
                            setTimeout(
                                resolve,
                                220
                            )
                    );


                    return getImageFromReaderPage(
                        page
                    );
                }
            );


        fallbackQueue =
            job.catch(
                () => {}
            );


        return job;
    }


    // ============================================================
    // PROBAR IMAGEN
    // ============================================================

    function testImage(
        url,
        page,
        element
    ) {

        return new Promise(
            (
                resolve,
                reject
            ) => {

                const img =
                    document.createElement(
                        'img'
                    );


                img.alt =
                    `Página ${page.number}`;


                img.decoding =
                    'async';


                img.dataset.page =
                    String(
                        page.number
                    );


                img.addEventListener(
                    'load',
                    () => {

                        /*
                         * Evita aceptar una imagen
                         * diminuta o placeholder.
                         */

                        if (
                            img.naturalWidth < 50

                            ||

                            img.naturalHeight < 50
                        ) {

                            img.remove();


                            reject(

                                new Error(
                                    `Imagen inválida: ${url}`
                                )
                            );


                            return;
                        }


                        resolve(
                            img
                        );

                    },
                    {
                        once:
                            true
                    }
                );


                img.addEventListener(
                    'error',
                    () => {

                        img.remove();


                        reject(

                            new Error(
                                `No cargó ${url}`
                            )
                        );

                    },
                    {
                        once:
                            true
                    }
                );


                element.appendChild(
                    img
                );


                img.src =
                    url;
            }
        );
    }


    // ============================================================
    // CARGAR PÁGINA
    // ============================================================

    async function loadPage(
        page,
        element,
        manualRetry = false
    ) {

        const reader =
            document.getElementById(
                APP_ID
            );


        /*
         * SEGURIDAD CONTRA GALERÍA ANTERIOR.
         */

        if (
            !reader

            ||

            reader.dataset.galleryId !==
                String(
                    page.galleryId
                )
        ) {

            return;
        }


        if (
            element.dataset.loaded ===
                '1'

            ||

            element.dataset.loading ===
                '1'
        ) {

            return;
        }


        element.dataset.loading =
            '1';


        element.dataset.error =
            '0';


        let loader =
            element.querySelector(
                '.jack-loader'
            );


        if (!loader) {

            loader =
                document.createElement(
                    'div'
                );


            loader.className =
                'jack-loader';


            element.appendChild(
                loader
            );
        }


        loader.textContent =
            `Cargando página ${page.number}…`;


        element
            .querySelectorAll(
                'img'
            )
            .forEach(
                img =>
                    img.remove()
            );


        let candidates =
            [];


        if (
            page.imageUrl
        ) {

            candidates.push(

                ...createImageVariants(
                    page.imageUrl
                )
            );
        }


        if (
            page.thumbUrl
        ) {

            candidates.push(

                ...thumbnailToFullImages(
                    page.thumbUrl
                )
            );
        }


        candidates =
            [
                ...new Set(
                    candidates.filter(
                        Boolean
                    )
                )
            ];


        // --------------------------------------------------------
        // PRIMER INTENTO
        // --------------------------------------------------------

        for (
            const candidate
            of
            candidates
        ) {

            try {

                await testImage(

                    candidate,

                    page,

                    element
                );


                page.imageUrl =
                    candidate;


                page.autoRetries =
                    0;


                element.dataset.loaded =
                    '1';


                element.dataset.loading =
                    '0';


                element.classList.add(
                    'jack-loaded'
                );


                loader.remove();


                return;


            } catch (_) {}
        }


        // --------------------------------------------------------
        // BUSCAR LA URL REAL
        // --------------------------------------------------------

        try {

            loader.textContent =
                `Buscando imagen real de página ${page.number}…`;


            const realUrl =
                await getImageFromReaderPageQueued(
                    page
                );


            for (
                const candidate
                of
                createImageVariants(
                    realUrl
                )
            ) {

                try {

                    await testImage(

                        candidate,

                        page,

                        element
                    );


                    page.imageUrl =
                        candidate;


                    page.autoRetries =
                        0;


                    element.dataset.loaded =
                        '1';


                    element.dataset.loading =
                        '0';


                    element.classList.add(
                        'jack-loaded'
                    );


                    loader.remove();


                    return;


                } catch (_) {}
            }


        } catch (
            error
        ) {

            console.warn(

                `[Cascada] Galería ${page.galleryId}, página ${page.number}:`,

                error
            );
        }


        // --------------------------------------------------------
        // REINTENTO AUTOMÁTICO
        // --------------------------------------------------------

        if (
            page.autoRetries < 2

            &&

            !manualRetry
        ) {

            page.autoRetries++;


            element.dataset.loading =
                '0';


            loader.textContent =
                `Reintentando página ${page.number}…`;


            setTimeout(
                () => {

                    loadPage(

                        page,

                        element,

                        false
                    );

                },
                1200 *
                page.autoRetries
            );


            return;
        }


        // --------------------------------------------------------
        // ERROR MANUAL
        // --------------------------------------------------------

        element.dataset.loading =
            '0';


        element.dataset.error =
            '1';


        loader.innerHTML =
            '';


        const errorText =
            document.createElement(
                'div'
            );


        errorText.textContent =
            `No cargó la página ${page.number}`;


        const retryButton =
            document.createElement(
                'button'
            );


        retryButton.className =
            'jack-retry';


        retryButton.textContent =
            '↻ Reintentar';


        retryButton.addEventListener(
            'click',
            event => {

                event.stopPropagation();


                page.autoRetries =
                    0;


                loadPage(

                    page,

                    element,

                    true
                );
            }
        );


        loader.append(

            errorText,

            retryButton
        );
    }


    // ============================================================
    // TOOLBAR
    // ============================================================

    function createToolbar(
        reader,
        galleryId,
        total,
        startPage
    ) {

        const toolbar =
            document.createElement(
                'div'
            );


        toolbar.className =
            'jack-toolbar';


        // --------------------------------------------------------
        // CONTADOR
        // --------------------------------------------------------

        const counter =
            document.createElement(
                'div'
            );


        counter.className =
            'jack-counter';


        counter.textContent =
            `${startPage} / ${total}`;


        // --------------------------------------------------------
        // PORTADA
        // --------------------------------------------------------

        const cover =
            document.createElement(
                'a'
            );


        cover.textContent =
            '⌂ Portada';


        cover.href =
            `${location.origin}/g/${galleryId}/`;


        cover.target =
            '_blank';


        cover.rel =
            'noopener noreferrer';


        cover.title =
            'Abrir portada en una pestaña nueva';


        // --------------------------------------------------------
        // ZOOM +
        // --------------------------------------------------------

        const plus =
            document.createElement(
                'button'
            );


        plus.textContent =
            '+ Zoom';


        plus.addEventListener(
            'click',
            () => {

                readerWidth =
                    Math.min(

                        readerWidth + 100,

                        1800
                    );


                reader.style.setProperty(

                    '--jack-width',

                    `${readerWidth}px`
                );
            }
        );


        // --------------------------------------------------------
        // ZOOM -
        // --------------------------------------------------------

        const minus =
            document.createElement(
                'button'
            );


        minus.textContent =
            '− Zoom';


        minus.addEventListener(
            'click',
            () => {

                readerWidth =
                    Math.max(

                        readerWidth - 100,

                        350
                    );


                reader.style.setProperty(

                    '--jack-width',

                    `${readerWidth}px`
                );
            }
        );


        // --------------------------------------------------------
        // AJUSTAR
        // --------------------------------------------------------

        const fit =
            document.createElement(
                'button'
            );


        fit.textContent =
            '↔ Ajustar';


        fit.addEventListener(
            'click',
            () => {

                readerWidth =
                    Math.max(

                        350,

                        window.innerWidth - 50
                    );


                reader.style.setProperty(

                    '--jack-width',

                    `${readerWidth}px`
                );
            }
        );


        // --------------------------------------------------------
        // CERRAR
        // --------------------------------------------------------

        const close =
            document.createElement(
                'button'
            );


        close.textContent =
            '✕ Cerrar';


        close.addEventListener(
            'click',
            closeReader
        );


        toolbar.append(

            counter,

            cover,

            plus,

            minus,

            fit,

            close
        );


        reader.appendChild(
            toolbar
        );


        return counter;
    }


    // ============================================================
    // CERRAR LECTOR
    // ============================================================

    function closeReader() {

        document
            .getElementById(
                APP_ID
            )
            ?.remove();


        document.body.style.overflow =
            oldBodyOverflow;


        addOpenButton();
    }


    // ============================================================
    // ABRIR LECTOR
    // ============================================================

    async function openReader() {

        /*
         * MUY IMPORTANTE:
         *
         * La ID se obtiene AHORA.
         *
         * No se utiliza una variable creada
         * cuando se cargó una galería anterior.
         */

        const route =
            getRoute();


        if (!route) {
            return;
        }


        const existing =
            document.getElementById(
                APP_ID
            );


        // --------------------------------------------------------
        // SI HAY UN LECTOR DE OTRA GALERÍA, BORRARLO
        // --------------------------------------------------------

        if (existing) {

            if (
                existing.dataset.galleryId ===
                    route.galleryId
            ) {

                return;
            }


            existing.remove();


            document.body.style.overflow =
                oldBodyOverflow;
        }


        addStyles();


        document
            .getElementById(
                BUTTON_ID
            )
            ?.remove();


        oldBodyOverflow =
            document.body.style.overflow;


        document.body.style.overflow =
            'hidden';


        /*
         * También reiniciamos la cola.
         */

        fallbackQueue =
            Promise.resolve();


        const reader =
            document.createElement(
                'div'
            );


        reader.id =
            APP_ID;


        /*
         * Guardamos qué galería pertenece
         * a ESTE lector.
         */

        reader.dataset.galleryId =
            route.galleryId;


        reader.dataset.routeSignature =
            route.signature;


        reader.style.setProperty(

            '--jack-width',

            `${readerWidth}px`
        );


        const start =
            document.createElement(
                'div'
            );


        start.className =
            'jack-start-message';


        start.textContent =
            `Cargando galería ${route.galleryId}…`;


        reader.appendChild(
            start
        );


        document.body.appendChild(
            reader
        );


        try {

            /*
             * Solicitar específicamente
             * la galería de la URL actual.
             */

            const galleryPages =
                await getGalleryPages(
                    route.galleryId
                );


            /*
             * COMPROBACIÓN CRÍTICA:
             *
             * Si el usuario cambió de galería
             * mientras se estaba cargando,
             * cancelar todo.
             */

            const liveRoute =
                getRoute();


            if (
                !liveRoute

                ||

                liveRoute.galleryId !==
                    route.galleryId
            ) {

                reader.remove();


                document.body.style.overflow =
                    oldBodyOverflow;


                addOpenButton();


                return;
            }


            start.remove();


            // ====================================================
            // CREAR PÁGINAS
            // ====================================================

            const pagesContainer =
                document.createElement(
                    'div'
                );


            pagesContainer.className =
                'jack-pages';


            const elementMap =
                new Map();


            for (
                const page
                of
                galleryPages
            ) {

                const item =
                    document.createElement(
                        'div'
                    );


                item.className =
                    'jack-page';


                item.dataset.page =
                    String(
                        page.number
                    );


                /*
                 * Incluimos la ID de galería
                 * en el ID HTML.
                 */

                item.id =
                    `jack-page-${route.galleryId}-${page.number}`;


                const loader =
                    document.createElement(
                        'div'
                    );


                loader.className =
                    'jack-loader';


                loader.textContent =
                    `Página ${page.number}`;


                item.appendChild(
                    loader
                );


                pagesContainer.appendChild(
                    item
                );


                elementMap.set(

                    page.number,

                    item
                );
            }


            reader.appendChild(
                pagesContainer
            );


            const startPage =
                Math.min(

                    route.currentPage,

                    galleryPages.length
                );


            const counter =
                createToolbar(

                    reader,

                    route.galleryId,

                    galleryPages.length,

                    startPage
                );


            // ====================================================
            // CARGA PROGRESIVA
            // ====================================================

            const loadObserver =
                new IntersectionObserver(

                    entries => {

                        for (
                            const entry
                            of
                            entries
                        ) {

                            if (
                                !entry.isIntersecting
                            ) {

                                continue;
                            }


                            const number =
                                Number(

                                    entry.target
                                        .dataset.page
                                );


                            const page =
                                galleryPages[
                                    number - 1
                                ];


                            if (page) {

                                loadPage(

                                    page,

                                    entry.target
                                );
                            }
                        }
                    },


                    {

                        root:
                            reader,

                        rootMargin:
                            '1800px 0px 1800px 0px',

                        threshold:
                            0
                    }
                );


            elementMap.forEach(
                element => {

                    loadObserver.observe(
                        element
                    );
                }
            );


            // ====================================================
            // CONTADOR
            // ====================================================

            const pageObserver =
                new IntersectionObserver(

                    entries => {

                        const visible =
                            entries

                                .filter(
                                    entry =>
                                        entry.isIntersecting
                                )

                                .sort(
                                    (
                                        a,
                                        b
                                    ) =>
                                        b.intersectionRatio -
                                        a.intersectionRatio
                                )[0];


                        if (visible) {

                            counter.textContent =

                                `${Number(visible.target.dataset.page)} / ${galleryPages.length}`;
                        }
                    },


                    {

                        root:
                            reader,

                        threshold:
                            [
                                .15,
                                .3,
                                .5,
                                .7
                            ]
                    }
                );


            elementMap.forEach(
                element => {

                    pageObserver.observe(
                        element
                    );
                }
            );


            // ====================================================
            // CARGAR PÁGINA INICIAL
            // ====================================================

            const actual =
                galleryPages[
                    startPage - 1
                ];


            const actualElement =
                elementMap.get(
                    startPage
                );


            if (
                actual &&
                actualElement
            ) {

                await loadPage(

                    actual,

                    actualElement
                );
            }


            // ====================================================
            // PRECARGAR ANTERIOR Y SIGUIENTE
            // ====================================================

            for (
                const number
                of
                [
                    startPage - 1,
                    startPage + 1
                ]
            ) {

                const page =
                    galleryPages[
                        number - 1
                    ];


                const element =
                    elementMap.get(
                        number
                    );


                if (
                    page &&
                    element
                ) {

                    loadPage(

                        page,

                        element
                    );
                }
            }


            // ====================================================
            // IR A LA PÁGINA CORRESPONDIENTE
            // ====================================================

            setTimeout(
                () => {

                    const target =
                        elementMap.get(
                            startPage
                        );


                    if (target) {

                        target.scrollIntoView(
                            {

                                block:
                                    'start'
                            }
                        );
                    }

                },
                100
            );


            console.log(

                `[Cascada] Galería ${route.galleryId}: ${galleryPages.length} páginas.`
            );


        } catch (
            error
        ) {

            console.error(
                '[Cascada]',
                error
            );


            start.innerHTML =

                `No pude crear el lector vertical.` +

                `<br><br>` +

                `${error.message}`;


            const close =
                document.createElement(
                    'button'
                );


            close.textContent =
                'Cerrar';


            close.style.cssText =

                'position:fixed;' +

                'right:20px;' +

                'top:20px;' +

                'z-index:2147483647;' +

                'padding:10px 15px;' +

                'cursor:pointer;';


            close.addEventListener(
                'click',
                closeReader
            );


            reader.appendChild(
                close
            );
        }
    }


    // ============================================================
    // BOTÓN LEER EN CASCADA
    // ============================================================

    function addOpenButton() {

        const route =
            getRoute();


        if (!route) {

            document
                .getElementById(
                    BUTTON_ID
                )
                ?.remove();


            return;
        }


        if (
            document.getElementById(
                APP_ID
            )
        ) {

            return;
        }


        addStyles();


        let button =
            document.getElementById(
                BUTTON_ID
            );


        /*
         * Si el botón pertenece a otra galería,
         * eliminarlo.
         */

        if (
            button

            &&

            button.dataset.galleryId !==
                route.galleryId
        ) {

            button.remove();

            button =
                null;
        }


        if (button) {
            return;
        }


        button =
            document.createElement(
                'button'
            );


        button.id =
            BUTTON_ID;


        button.dataset.galleryId =
            route.galleryId;


        button.textContent =
            '⇣ Leer en cascada';


        /*
         * No se guarda galleryId dentro
         * del listener.
         *
         * openReader() vuelve a consultar
         * la URL actual.
         */

        button.addEventListener(
            'click',
            () => {

                openReader();
            }
        );


        document.body.appendChild(
            button
        );
    }


    // ============================================================
    // SINCRONIZAR AL CAMBIAR DE GALERÍA
    // ============================================================

    function syncToCurrentRoute(
        {
            allowAutoOpen = true
        } = {}
    ) {

        const route =
            getRoute();


        if (!route) {

            document
                .getElementById(
                    APP_ID
                )
                ?.remove();


            document
                .getElementById(
                    BUTTON_ID
                )
                ?.remove();


            document.body.style.overflow =
                oldBodyOverflow;


            return;
        }


        const existing =
            document.getElementById(
                APP_ID
            );


        /*
         * Si navegamos a otra galería,
         * cerrar automáticamente el lector viejo.
         */

        if (
            existing

            &&

            existing.dataset.galleryId !==
                route.galleryId
        ) {

            existing.remove();


            document.body.style.overflow =
                oldBodyOverflow;
        }


        addOpenButton();


        if (
            autoOpenTimer
        ) {

            clearTimeout(
                autoOpenTimer
            );


            autoOpenTimer =
                null;
        }


        /*
         * Si abrimos directamente:
         *
         * /g/123456/5/
         *
         * abrir cascada desde esa página.
         */

        if (
            allowAutoOpen

            &&

            route.isReaderPage

            &&

            !document.getElementById(
                APP_ID
            )
        ) {

            const expected =
                route.signature;


            autoOpenTimer =
                setTimeout(
                    () => {

                        const now =
                            getRoute();


                        if (
                            now?.signature ===
                                expected
                        ) {

                            openReader();
                        }

                    },
                    350
                );
        }
    }


    // ============================================================
    // DETECTAR CAMBIOS DE URL
    // ============================================================

    function installNavigationWatch() {

        lastRouteSignature =
            getRoute()?.signature ||
            '';


        // --------------------------------------------------------
        // ATRÁS / ADELANTE
        // --------------------------------------------------------

        window.addEventListener(
            'popstate',
            () => {

                setTimeout(
                    () => {

                        syncToCurrentRoute();

                    },
                    0
                );
            }
        );


        // --------------------------------------------------------
        // BFCache / RESTAURAR PESTAÑA
        // --------------------------------------------------------

        window.addEventListener(
            'pageshow',
            () => {

                setTimeout(
                    () => {

                        syncToCurrentRoute();

                    },
                    0
                );
            }
        );


        // --------------------------------------------------------
        // PUSHSTATE
        // --------------------------------------------------------

        const originalPushState =
            history.pushState;


        history.pushState =
            function (
                ...args
            ) {

                const result =
                    originalPushState.apply(
                        this,
                        args
                    );


                setTimeout(
                    () => {

                        syncToCurrentRoute();

                    },
                    0
                );


                return result;
            };


        // --------------------------------------------------------
        // REPLACESTATE
        // --------------------------------------------------------

        const originalReplaceState =
            history.replaceState;


        history.replaceState =
            function (
                ...args
            ) {

                const result =
                    originalReplaceState.apply(
                        this,
                        args
                    );


                setTimeout(
                    () => {

                        syncToCurrentRoute();

                    },
                    0
                );


                return result;
            };


        // --------------------------------------------------------
        // COMPROBACIÓN EXTRA
        // --------------------------------------------------------

        setInterval(
            () => {

                const signature =
                    getRoute()?.signature ||
                    '';


                if (
                    signature !==
                    lastRouteSignature
                ) {

                    lastRouteSignature =
                        signature;


                    syncToCurrentRoute();
                }

            },
            700
        );
    }


    // ============================================================
    // TECLADO
    // ============================================================

    document.addEventListener(
        'keydown',
        event => {

            const tag =
                document.activeElement
                    ?.tagName
                    ?.toLowerCase();


            if (
                tag ===
                    'input'

                ||

                tag ===
                    'textarea'
            ) {

                return;
            }


            // ----------------------------------------------------
            // ESC = CERRAR
            // ----------------------------------------------------

            if (
                event.key ===
                    'Escape'

                &&

                document.getElementById(
                    APP_ID
                )
            ) {

                closeReader();

                return;
            }


            // ----------------------------------------------------
            // V = ABRIR / CERRAR
            // ----------------------------------------------------

            if (
                (
                    event.key ===
                        'v'

                    ||

                    event.key ===
                        'V'
                )

                &&

                !event.ctrlKey

                &&

                !event.altKey

                &&

                !event.metaKey
            ) {

                if (
                    document.getElementById(
                        APP_ID
                    )
                ) {

                    closeReader();


                } else {

                    openReader();
                }
            }
        }
    );


    // ============================================================
    // INICIO
    // ============================================================

    installNavigationWatch();

    syncToCurrentRoute();

})();