Gallery Scroll Navigator (custom setting)

Automatically navigate next/previous pages for gallery sites by vertical scrolling, with scroll buffer for turning pages. Forked from [Gallery Scroll Navigator] by [Yukiteru]. User can now apply this script to any custom websites.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name                 Gallery Scroll Navigator (custom setting)
// @namespace            https://sleazyfork.org/
// @version              2.0
// @description          Automatically navigate next/previous pages for gallery sites by vertical scrolling, with scroll buffer for turning pages. Forked from [Gallery Scroll Navigator] by [Yukiteru]. User can now apply this script to any custom websites.
// @author               php
// @match                https://hitomi.la/*
// @match                https://www.pixiv.net/*
// @match                https://nhentai.net/*
// @match                https://exhentai.org/*
// @match                https://imhentai.xxx/*
// @grant                GM_setValue
// @grant                GM_getValue
// @grant                GM_deleteValue
// @grant                GM_registerMenuCommand
// @run-at               document-end
// @license              MIT
// ==/UserScript==

(function() {
    'use strict';

    /* ==========================================================================
       0. Config 模組:集中管理所有可自訂參數、設定檔與 UI 樣式
       ========================================================================== */
    const Config = {
        DefaultSites: [
            { name: "Hitomi", host: "hitomi.la", code: "try { const pageContainer = [...document.querySelectorAll('.page-container li')]; const currentPage = pageContainer.filter(i => i.textContent !== '...').find(i => i.children.length === 0); const targetLi = direction === 'next' ? currentPage.nextElementSibling : currentPage.previousElementSibling; return targetLi ? targetLi.querySelector('a') : null; } catch(e) { return null; }" },
            { name: "Pixiv", host: "www.pixiv.net", code: "const selector = `nav:has(button) > a:${direction === 'next' ? 'last' : 'first'}-child`; const pageButton = document.querySelector(selector); return pageButton && !pageButton.hasAttribute('hidden') ? pageButton : null;" },
            { name: "Nhentai", host: "nhentai.net", code: "const selector = direction === 'next' ? '.next' : '.previous'; const pageButton = document.querySelector(selector); return pageButton || null;" },
            { name: "Exhentai", host: "exhentai.org", code: "const selector = direction === 'next' ? 'a#dnext' : 'a#dprev'; const pageButton = document.querySelector(selector); return pageButton || null;" },
            { name: "Imhentai", host: "imhentai.xxx", code: "const selector = direction === 'next' ? 'ul.pagination > li.page-item:last-child > a' : 'ul.pagination > li.page-item:first-child > a'; const pageButton = document.querySelector(selector); return pageButton && !pageButton.href.includes('#') ? pageButton : null;" }
        ],

        DefaultSettings: {
            mouseScrolls: 8,
            touchSensitivity: 5,
            disableMobileRefresh: true,
            disablePrev: false,
            accumulateMode: true  // 預設開啟累加法
        },

        System: {
            progressBarColor: 'red',
            progressBarHeight: '3px',
            progressBarZIndex: '2147483647',
            touchDistanceBaseline: 600,
            touchDistanceStep: 50,
            bottomTolerance: 5,
            observerSelectors: ['nav', '.pagination', '.pager', '.page-nav', '#pagination', 'footer'],
            safetyIntervalMs: 5000,
            menuName: "Configuration",
            wheelResetDelay: 300
        },

        UI: {
            CSS: `
                /* 動態類別:只有當使用者勾選停用重新整理時,才會套用此類別到 html/body 上 */
                html.gsn-no-refresh,
                body.gsn-no-refresh {
                    overscroll-behavior-y: contain !important;
                }

                /* --- 進度條核心樣式 --- */
                .gsn-progress-line {
                    position: fixed;
                    left: 0; right: 0;
                    pointer-events: none;
                    transform-origin: center;
                    transform: scaleX(0);
                    transition: transform 0.1s cubic-bezier(0, 0, 0.2, 1);
                }
                .gsn-progress-line.top { top: 0; }
                .gsn-progress-line.bottom { bottom: 0; }
                .gsn-progress-line.no-transition { transition: none !important; }
                .gsn-progress-line.complete {
                    filter: brightness(1.8) saturate(1.5) !important;
                    box-shadow: 0 0 12px 1px rgba(255, 255, 255, 0.9), 0 0 5px rgba(255, 0, 0, 0.8) !important;
                }
                /* --- 設定面板樣式 --- */
                #gsn-settings-overlay {
                    position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                    background: rgba(0,0,0,0.5); z-index: 10000;
                    display: flex; align-items: center; justify-content: center;
                    font-family: system-ui, -apple-system, sans-serif;
                }
                .gsn-container {
                    background: rgba(255, 255, 255, 0.7) !important;
                    backdrop-filter: blur(15px) saturate(180%) !important;
                    -webkit-backdrop-filter: blur(15px) saturate(180%) !important;
                    padding: 30px !important; border-radius: 20px; width: 90%; max-width: 600px;
                    box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important;
                    border: 1px solid rgba(255, 255, 255, 0.5) !important; border-top: 5px solid #007bff;
                    max-height: 90vh; overflow-y: auto;
                }
                .gsn-title { margin: 0 0 20px 0; text-align: center; color: #222; font-size: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 12px; }
                .gsn-section { margin-bottom: 20px; }
                .gsn-section-title { color: #007bff; font-weight: bold; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
                .gsn-label { display: block; margin-bottom: 8px; font-size: 0.95rem; color: #444 !important; }
                .gsn-input-num {
                    width: 100% !important; padding: 10px !important; border: 1.5px solid #ddd !important;
                    border-radius: 6px !important; box-sizing: border-box !important; font-size: 1rem !important;
                    color: #333 !important; background: #ffffff !important; color-scheme: light !important;
                    box-shadow: none !important; outline: none !important;
                }
                .gsn-input-num:focus { border-color: #007bff !important; box-shadow: 0 0 0 2px rgba(0,123,255,0.25) !important; }
                .gsn-input-range { width: 100%; cursor: pointer; margin-top: 8px; }
                .gsn-display-val { text-align: right; font-size: 0.85rem; color: #666; margin-top: 4px; }
                .gsn-checkbox-group { display: flex; align-items: center; margin-bottom: 10px; }
                .gsn-checkbox-group input[type="checkbox"] { margin-right: 10px; width: 18px; height: 18px; cursor: pointer; }
                .gsn-checkbox-group label { cursor: pointer; font-size: 0.95rem; color: #444 !important; margin: 0; }
                .gsn-btn-group { display: flex; gap: 12px; margin-top: 25px; justify-content: flex-end; }
                .gsn-btn { flex: 0 0 100px; padding: 12px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: filter 0.2s; }
                .gsn-btn:active { opacity: 0.8; }
                .gsn-btn-save { background: #007bff; color: white; }
                .gsn-btn-cancel { background: #f0f0f0; color: #444; }
                textarea { color: #333 !important; background: #fff !important; font-family: monospace !important; font-size: 0.85rem !important; padding: 10px !important; min-height: 120px; }
                #gsn-del-site, #gsn-reset { background-color: #dc3545; color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; }
                #gsn-del-site:hover, #gsn-reset:hover, #gsn-save-all:hover, #gsn-cancel:hover { filter: brightness(0.9); }
            `,
            getHTML: (settings) => `
                <div class="gsn-container">
                    <h2 class="gsn-title">Configuration</h2>

                    <div class="gsn-section">
                        <p class="gsn-section-title">Sensitivity</p>
                        <label class="gsn-label">(Desktop) Mouse Wheel Scrolls (1-20):</label>
                        <input type="number" id="gsn-mouse-val" class="gsn-input-num" value="${settings.mouseScrolls}">
                        <label class="gsn-label" style="margin-top:10px;">(Mobile) Touch Sensitivity (1-10):</label>
                        <input type="range" id="gsn-touch-val" class="gsn-input-range" min="1" max="10" value="${settings.touchSensitivity}">
                        <div class="gsn-display-val">Value: <span id="gsn-touch-display">${settings.touchSensitivity}</span></div>
                    </div>

                    <div class="gsn-section">
                        <p class="gsn-section-title">Scroller Behaviour</p>
                        <div class="gsn-checkbox-group">
                            <input type="checkbox" id="gsn-disable-refresh" ${settings.disableMobileRefresh ? 'checked' : ''}>
                            <label for="gsn-disable-refresh">Disable Pull-to-Refresh (Mobile)</label>
                        </div>
                        <div class="gsn-checkbox-group">
                            <input type="checkbox" id="gsn-disable-prev" ${settings.disablePrev ? 'checked' : ''}>
                            <label for="gsn-disable-prev">Disable Previous Page Navigation (Only allow Next Page)</label>
                        </div>
                        <div class="gsn-checkbox-group">
                            <input type="checkbox" id="gsn-accumulate-mode" ${settings.accumulateMode ? 'checked' : ''}>
                            <label for="gsn-accumulate-mode">Enable Page Turning Progress Accumulate Mode <br>
                            (won't reset progress bar until screen position leaving trigger zone)</label>
                        </div>
                    </div>

                    <div class="gsn-section">
                        <p class="gsn-section-title">Site Manager</p>
                        <div style="display: flex; gap: 5px; margin-bottom: 10px;">
                            <select id="gsn-site-select" class="gsn-input-num"><option value="">-- Add New Site --</option></select>
                            <button id="gsn-del-site" class="gsn-btn gsn-btn-cancel" style="flex: 0 0 60px;">Del</button>
                        </div>
                        <input type="text" id="gsn-site-name" placeholder="Site Name" class="gsn-input-num" style="margin-bottom: 5px;">
                        <input type="text" id="gsn-site-host" placeholder="Host (e.g. example.com)" class="gsn-input-num" style="margin-bottom: 5px;">
                        <textarea id="gsn-site-code" placeholder="/* Function Body of Page Turning Function: getPageButton(direction) {...} */&#10;&#10; Example: return document.querySelector('.next-btn');&#10;&#10; * @param {string} direction: 'next' or 'prev', indicate the page turning direction.&#10; * @returns {HTMLElement}: clickable element (next/previous page button) depending on direction.&#10; * Note: Return null if clickable element doesn't exist. (e.g., no previous page in the 1st page)" class="gsn-input-num" rows="11"></textarea>
                    </div>

                    <div class="gsn-btn-group">
                        <button id="gsn-reset" class="gsn-btn gsn-btn-cancel">Reset</button>
                        <button id="gsn-cancel" class="gsn-btn gsn-btn-cancel">Close</button>
                        <button id="gsn-save-all" class="gsn-btn gsn-btn-save">Save</button>
                    </div>
                </div>
            `
        }
    };

    /* ==========================================================================
       1. Logger 模組:負責統一處理終端機日誌輸出
       ========================================================================== */
    const Logger = {
        info: (message) => console.log(`[Scroll Pager]: ${message}`),
        error: (message, err) => console.error(`[Scroll Pager Error]: ${message}`, err)
    };

    /* ==========================================================================
       2. Store 模組:負責與 Tampermonkey 的存儲 API (GM_setValue/getValue) 溝通
       ========================================================================== */
    const Store = {
        getSetting: (key) => GM_getValue(key, Config.DefaultSettings[key]),
        setSetting: (key, value) => GM_setValue(key, value),

        getSites: () => {
            const stored = GM_getValue('customSites', null);
            return stored ? JSON.parse(stored) : Config.DefaultSites;
        },
        saveSites: (sites) => GM_setValue('customSites', JSON.stringify(sites)),

        resetAll: () => {
            GM_deleteValue('customSites');
            GM_deleteValue('mouseScrolls');
            GM_deleteValue('touchSensitivity');
            GM_deleteValue('disableMobileRefresh');
            GM_deleteValue('disablePrev');
            GM_deleteValue('accumulateMode');
        }
    };

    /* ==========================================================================
       3. Progress 模組:負責在畫面上建立、更新及重置頂部/底部的紅色進度條
       ========================================================================== */
    const Progress = {
        bars: { top: null, bottom: null },

        init: () => {
            Progress.bars.bottom = Progress.createBar('bottom');
            Progress.bars.top = Progress.createBar('top');
        },

        createBar: (position) => {
            const bar = document.createElement('div');
            // 只依賴 CSS class,不再 hardcode 寫入 style
            bar.classList.add('gsn-progress-line', position);
            document.body.appendChild(bar);
            return bar;
        },

        update: (bar, currentProgress, maxProgress) => {
            if (!bar) return;
            const scale = Math.min(currentProgress / maxProgress, 1);
            bar.style.transform = `scaleX(${scale})`;

            // 當進度達到 100% 時加上 complete 類別,否則移除
            if (scale >= 1) bar.classList.add('complete');
            else bar.classList.remove('complete');
        },

        reset: () => {
            [Progress.bars.top, Progress.bars.bottom].forEach(bar => {
                if (!bar) return;
                bar.classList.add('no-transition');
                bar.classList.remove('complete'); // 重置時移除發光效果
                bar.style.transform = 'scaleX(0)';
                void bar.offsetWidth;
                bar.classList.remove('no-transition');
            });
        }
    };

    /* ==========================================================================
       4. Navigator 模組:負責尋找當前網站的目標按鈕,並執行點擊翻頁動作
       ========================================================================== */
    const Navigator = {
        getCurrentSite: () => {
            const site = Store.getSites().find(s => location.host === s.host);
            if (!site) return null;
            return {
                host: site.host,
                getPageButton: new Function('direction', site.code)
            };
        },

        triggerPage: (direction) => {
            const site = Navigator.getCurrentSite();
            const btn = site?.getPageButton(direction);
            if (btn) btn.click();
        },

        checkIsBottom: () => {
            const scrollHeight = document.documentElement.scrollHeight;
            const scrollTop = window.scrollY || document.documentElement.scrollTop;
            return (scrollTop + window.innerHeight) >= (scrollHeight - Config.System.bottomTolerance);
        },

        checkIsTop: () => window.scrollY === 0
    };

    /* ==========================================================================
       5. EventController 模組:負責監聽滑鼠滾輪與觸控事件,計算滾動進度
       ========================================================================== */
    const EventController = {
        state: {
            scrollCounter: 0,
            disablePaging: false,
            touchStartY: 0,
            lastTouchY: 0,           // [修正] 初始化 lastTouchY
            touchAccumulated: 0,     // [修正] 初始化累計距離變數
            wheelTimer: null,
            isBound: false
        },

        resetState: () => {
            // 清除計時器防止重複執行
            if (EventController.state.wheelTimer) {
                clearTimeout(EventController.state.wheelTimer);
                EventController.state.wheelTimer = null;
            }

            EventController.state.scrollCounter = 0;
            EventController.state.touchAccumulated = 0;
            EventController.state.disablePaging = false; // 離開區域後,允許再次觸發翻頁
            Progress.reset();
        },
        handleScrollIntent: (isScrollingDown) => {
            if (EventController.state.disablePaging) return { valid: false };

            const isBottom = Navigator.checkIsBottom();
            const isTop = Navigator.checkIsTop();
            const site = Navigator.getCurrentSite();

            const intentPrev = isTop && !isScrollingDown && site?.getPageButton('prev');
            const intentNext = isBottom && isScrollingDown && site?.getPageButton('next');

            if (intentPrev || intentNext) {
                return {
                    valid: true,
                    direction: intentPrev ? 'prev' : 'next',
                    bar: intentPrev ? Progress.bars.top : Progress.bars.bottom
                };
            }
            EventController.resetState();
            return { valid: false };
        },

        bindMouse: () => {
            document.addEventListener("wheel", event => {
                const isScrollingDown = (event.wheelDelta) ? event.wheelDelta < 0 : event.deltaY > 0;
                const intent = EventController.handleScrollIntent(isScrollingDown);

                // 1. 清除上一次的計時器 (如果有的話)
                if (EventController.state.wheelTimer) {
                    clearTimeout(EventController.state.wheelTimer);
                }

                if (intent.valid) {
                    if (intent.direction === 'prev' && Store.getSetting('disablePrev')) return;

                    EventController.state.scrollCounter++;
                    const maxScrolls = Store.getSetting('mouseScrolls');
                    Progress.update(intent.bar, EventController.state.scrollCounter, maxScrolls);

                    if (EventController.state.scrollCounter >= maxScrolls) {
                        EventController.state.disablePaging = true;
                        Navigator.triggerPage(intent.direction);
                    }

                    // 2. [核心邏輯] 如果關閉累加模式,設定一個計時器
                    // 當使用者停止捲動 500 毫秒後,自動重置進度
                    if (!Store.getSetting('accumulateMode')) {
                        EventController.state.wheelTimer = setTimeout(() => {
                            // 再次確認是否仍未觸發翻頁,才執行重置
                            if (!EventController.state.disablePaging) {
                                EventController.resetState();
                            }
                        }, Config.System.wheelResetDelay);
                    }
                } else {
                    // 如果不在觸發區,且非累加模式,立即重置
                    if (!Store.getSetting('accumulateMode')) {
                        EventController.resetState();
                    }
                }
            });
        },

        bindTouch: () => {
            document.addEventListener("touchstart", e => {
                EventController.state.touchStartY = e.touches[0].clientY;
                EventController.state.lastTouchY = e.touches[0].clientY;
            }, { passive: true });

            document.addEventListener("touchmove", e => {
                const touchCurrentY = e.touches[0].clientY;
                const deltaY = touchCurrentY - EventController.state.lastTouchY;
                const isScrollingDown = touchCurrentY < EventController.state.touchStartY;
                const intent = EventController.handleScrollIntent(isScrollingDown);

                if (intent.valid) {
                    if (intent.direction === 'prev' && Store.getSetting('disablePrev')) return;
                    if (intent.direction === 'prev' && Store.getSetting('disableMobileRefresh') && e.cancelable) {
                        e.preventDefault();
                    }

                    // 累加滑動位移
                    EventController.state.touchAccumulated += Math.abs(deltaY);
                    const sensitivity = Store.getSetting('touchSensitivity');
                    const maxDistance = Config.System.touchDistanceBaseline - (sensitivity * Config.System.touchDistanceStep);

                    Progress.update(intent.bar, EventController.state.touchAccumulated, maxDistance);

                    if (EventController.state.touchAccumulated >= maxDistance) {
                        EventController.state.disablePaging = true;
                        Navigator.triggerPage(intent.direction);
                    }
                }
                EventController.state.lastTouchY = touchCurrentY;
            }, { passive: false });

            // [新增] 監聽手指放開
            document.addEventListener("touchend", () => {
                // 如果使用者關閉了「累加模式」,手指放開時進度條歸零
                if (!Store.getSetting('accumulateMode')) {
                    EventController.resetState();
                }
            });
        }
    };

    /* ==========================================================================
       6. UI 模組:負責產生、操作設定面板,以及動態注入 CSS
       ========================================================================== */
    const UI = {
        injectStyles: () => {
            if (!document.getElementById('gsn-global-styles')) {
                const styleEl = document.createElement('style');
                styleEl.id = 'gsn-global-styles';

                // 動態將 Config.System 的變數轉換為 CSS,並合併到原本的 CSS 模板中
                const dynamicSystemCSS = `
                    .gsn-progress-line {
                        height: ${Config.System.progressBarHeight};
                        background-color: ${Config.System.progressBarColor};
                        z-index: ${Config.System.progressBarZIndex} !important;
                    }
                `;
                styleEl.textContent = Config.UI.CSS + dynamicSystemCSS;
                document.head.appendChild(styleEl);
            }
            UI.applyDynamicClasses();
        },

        applyDynamicClasses: () => {
            const disableRefresh = Store.getSetting('disableMobileRefresh');
            if (disableRefresh) {
                document.documentElement.classList.add('gsn-no-refresh');
                document.body.classList.add('gsn-no-refresh');
            } else {
                document.documentElement.classList.remove('gsn-no-refresh');
                document.body.classList.remove('gsn-no-refresh');
            }
        },

        open: () => {
            if (document.getElementById('gsn-settings-overlay')) return;

            const overlay = document.createElement('div');
            overlay.id = 'gsn-settings-overlay';

            const currentSettings = {
                mouseScrolls: Store.getSetting('mouseScrolls'),
                touchSensitivity: Store.getSetting('touchSensitivity'),
                disableMobileRefresh: Store.getSetting('disableMobileRefresh'),
                disablePrev: Store.getSetting('disablePrev'),
                accumulateMode: Store.getSetting('accumulateMode')
            };
            overlay.innerHTML = Config.UI.getHTML(currentSettings);
            document.body.appendChild(overlay);

            const touchInput = document.getElementById('gsn-touch-val');
            const touchDisplay = document.getElementById('gsn-touch-display');
            const select = document.getElementById('gsn-site-select');
            const nameInput = document.getElementById('gsn-site-name');
            const hostInput = document.getElementById('gsn-site-host');
            const codeInput = document.getElementById('gsn-site-code');

            touchInput.oninput = (e) => touchDisplay.innerText = e.target.value;

            const refreshDropdown = () => {
                select.innerHTML = '<option value="">-- Add New Site --</option>';
                Store.getSites().forEach((s, i) => {
                    const opt = document.createElement('option');
                    opt.value = i;
                    opt.textContent = s.name;
                    select.appendChild(opt);
                });
            };
            refreshDropdown();

            select.onchange = (e) => {
                const site = Store.getSites()[e.target.value];
                nameInput.value = site?.name || '';
                hostInput.value = site?.host || '';
                codeInput.value = site?.code || '';
            };

            document.getElementById('gsn-del-site').onclick = () => {
                if (select.value === "") return alert("Please select a site first.");
                if (confirm(`Delete site profile: "${select.options[select.selectedIndex].text}"?`)) {
                    let sites = Store.getSites();
                    sites.splice(select.value, 1);
                    Store.saveSites(sites);
                    refreshDropdown();
                    nameInput.value = hostInput.value = codeInput.value = '';
                }
            };

            document.getElementById('gsn-reset').onclick = () => {
                if (confirm("Reset all settings to factory default?")) {
                    Store.resetAll();
                    location.reload();
                }
            };

            document.getElementById('gsn-save-all').onclick = () => {
                Store.setSetting('mouseScrolls', parseInt(document.getElementById('gsn-mouse-val').value));
                Store.setSetting('touchSensitivity', parseInt(document.getElementById('gsn-touch-val').value));
                Store.setSetting('disableMobileRefresh', document.getElementById('gsn-disable-refresh').checked);
                Store.setSetting('disablePrev', document.getElementById('gsn-disable-prev').checked);
                Store.setSetting('accumulateMode', document.getElementById('gsn-accumulate-mode').checked);

                UI.applyDynamicClasses();

                if (nameInput.value && hostInput.value && codeInput.value) {
                    let sites = Store.getSites();
                    if (select.value !== "") sites[select.value] = { name: nameInput.value, host: hostInput.value, code: codeInput.value };
                    else sites.push({ name: nameInput.value, host: hostInput.value, code: codeInput.value });
                    Store.saveSites(sites);
                }
                overlay.remove();
                Logger.info('Settings saved!');
            };

            document.getElementById('gsn-cancel').onclick = () => { overlay.remove(); };
        }
    };

    /* ==========================================================================
       7. App 主程式:管理腳本啟動生命週期與動態觀察者
       ========================================================================== */
    const App = {
        tryBindEvents: () => {
            if (EventController.state.isBound) return;
            const site = Navigator.getCurrentSite();
            if (!site) return;

            if (site.getPageButton('next') || site.getPageButton('prev')) {
                EventController.bindMouse();
                EventController.bindTouch();
                EventController.state.isBound = true;
                Logger.info('Events bound successfully.');
            }
        },

        startObserver: () => {
            if (EventController.state.isBound) return;
            const selectors = Config.System.observerSelectors;
            let targetNode = document.body;

            for (const selector of selectors) {
                const found = document.querySelector(selector);
                if (found) { targetNode = found; break; }
            }

            const observer = new MutationObserver((mutations, obs) => {
                App.tryBindEvents();
                if (EventController.state.isBound) {
                    obs.disconnect();
                    Logger.info('Observer disconnected: Buttons found.');
                }
            });
            observer.observe(targetNode, { childList: true, subtree: true });
        },

        init: () => {
            GM_registerMenuCommand(Config.System.menuName, UI.open);
            UI.injectStyles();

            Progress.init();
            App.tryBindEvents();
            App.startObserver();

            window.addEventListener('load', App.tryBindEvents);
            window.addEventListener('popstate', () => {
                EventController.state.isBound = false;
                App.tryBindEvents();
                App.startObserver();
            });
            window.navigation?.addEventListener("navigate", () => {
                EventController.state.disablePaging = false;
            });

            const safetyInterval = setInterval(() => {
                if (EventController.state.isBound) return clearInterval(safetyInterval);
                App.tryBindEvents();
            }, Config.System.safetyIntervalMs);
        }
    };

    App.init();

})();