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.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name                 Gallery Scroll Navigator (custom setting)
// @namespace            https://sleazyfork.org/
// @version              3.1.9
// @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/*
// @match                https://rule34.xxx/*
// @match                https://kemono.cr/*
// @match                https://danbooru.donmai.us/*
// @match                https://www.wnacg.com/*
// @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[aria-label='Pagination'] > 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;" },
            { name: "Rule34", host: "rule34.xxx", code: "const selector = direction === 'next' ? 'a[alt=\"next\"]' : 'a[alt=\"back\"]'; const pageButton = document.querySelector(selector); return pageButton || null;" },
            { name: "Kemono", host: "kemono.cr", code: "const selector = direction === 'next' ? 'div.paginator > menu > a:nth-last-child(2):not(.pagination-button-disabled)' : 'div.paginator > menu > a:nth-child(2):not(.pagination-button-disabled)'; const pageButton = document.querySelector(selector); return pageButton || null;" },
            { name: "Danbooru", host: "danbooru.donmai.us", code: "const selector = direction === 'next' ? 'a.paginator-next' : 'a.paginator-prev'; const pageButton = document.querySelector(selector); return pageButton || null;" },
            { name: "Wnacg", host: "www.wnacg.com", code: "const selector = direction === 'next' ? '.next > a' : '.prev > a'; const pageButton = document.querySelector(selector); return pageButton || null;" }
        ],

        DefaultSettings: {
            mouseScrolls: 8,
            touchSensitivity: 7,
            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: `


                /* --- 進度條核心樣式 --- */
                .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 el = document.scrollingElement || document.documentElement;
            const scrollTop = el.scrollTop || window.scrollY;

            // If a gesture is active, dynamically expand the edge zone by 150px to absorb the URL bar animation
            const isActive = EventController.state.touchAccumulated > 0 || EventController.state.scrollCounter > 0;
            const buffer = isActive ? 300 : 0; // 擴大緩衝區至 300,吸收 Edge 巨大的導覽列高度

            return Math.ceil(scrollTop + window.innerHeight) >= (el.scrollHeight - Config.System.bottomTolerance - buffer);
        },

        checkIsTop: () => {
            const el = document.scrollingElement || document.documentElement;
            const scrollTop = el.scrollTop || window.scrollY;

            // Dynamically expand the top zone for the same reason
            const isActive = EventController.state.touchAccumulated > 0 || EventController.state.scrollCounter > 0;
            const buffer = isActive ? 300 : 0; // 擴大緩衝區至 300

            return scrollTop <= buffer;
        }
    };

    /* ==========================================================================
       5. EventController 模組:負責監聽滑鼠滾輪與觸控事件,計算滾動進度
       ========================================================================== */
    const EventController = {
        state: {
            scrollCounter: 0,
            disablePaging: false,
            touchStartY: 0,
            lastTouchY: 0,
            touchAccumulated: 0,
            wheelTimer: null,
            isBound: false,
            isUnloading: 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 };
        },

        handleWheelEvent: (event) => {
            const isScrollingDown = (event.wheelDelta) ? event.wheelDelta < 0 : event.deltaY > 0;
            const intent = EventController.handleScrollIntent(isScrollingDown);

            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);
                    EventController.state.scrollCounter = 0;
                }

                if (!Store.getSetting('accumulateMode')) {
                    EventController.state.wheelTimer = setTimeout(() => {
                        if (!EventController.state.disablePaging) {
                            EventController.resetState();
                        }
                    }, Config.System.wheelResetDelay);
                }
            } else {
                // [修復] 當滾輪方向反轉,或離開邊界時,立刻強制歸零
                // 加上 > 0 的判斷,避免在網頁中間正常瀏覽時狂刷 layout 浪費效能
                if (EventController.state.scrollCounter > 0) {
                    EventController.resetState();
                }
            }
        },

        handleTouchStart: (e) => {
            EventController.state.touchStartY = e.touches[0].clientY;
            EventController.state.lastTouchY = e.touches[0].clientY;
        },

        handleTouchMove: (e) => {
            const touchCurrentY = e.touches[0].clientY;
            const deltaY = touchCurrentY - EventController.state.lastTouchY;

            // 1. 如果手指沒有垂直移動,直接略過
            if (deltaY === 0) return;

            // 2. [核心修復] 使用「瞬時方向」(deltaY) 判斷,而非相對於起點
            // deltaY < 0 代表手指往上滑,畫面往下滾 (意圖觸發下一頁)
            const isScrollingDown = deltaY < 0;
            const intent = EventController.handleScrollIntent(isScrollingDown);

            if (intent.valid) {
                if (intent.direction === 'prev' && Store.getSetting('disablePrev')) {
                    EventController.state.lastTouchY = touchCurrentY;
                    return;
                }

                // 🛡️ 防止 Edge 等瀏覽器導覽列劫持手勢
                if (e.cancelable) {
                    if (intent.direction === 'prev' && Store.getSetting('disableMobileRefresh')) {
                        e.preventDefault();
                    } else if (intent.direction === 'next') {
                        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.touchAccumulated = 0;
                }
            } else {
                // 3. [核心修復] 一旦瞬時方向不符 (例如反向滑動),且當前有累積進度
                // 立刻將進度條強制歸零,實現「變更方向立刻重置」的完美體驗
                if (EventController.state.touchAccumulated > 0) {
                    EventController.resetState();
                }
            }

            EventController.state.lastTouchY = touchCurrentY;
        },

        handleTouchEnd: () => {
            if (!Store.getSetting('accumulateMode')) {
                EventController.resetState();
            }
        },

        bindMouse: () => {
            document.removeEventListener("wheel", EventController.handleWheelEvent);
            document.addEventListener("wheel", EventController.handleWheelEvent);
        },

        bindTouch: () => {
            document.removeEventListener("touchstart", EventController.handleTouchStart);
            document.removeEventListener("touchmove", EventController.handleTouchMove);
            document.removeEventListener("touchend", EventController.handleTouchEnd);
            document.removeEventListener("touchcancel", EventController.handleTouchEnd);

            document.addEventListener("touchstart", EventController.handleTouchStart, { passive: true });
            document.addEventListener("touchmove", EventController.handleTouchMove, { passive: false });
            document.addEventListener("touchend", EventController.handleTouchEnd);
            document.addEventListener("touchcancel", EventController.handleTouchEnd);
        }
    };

    /* ==========================================================================
       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);
            }

        },



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



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

            // Detect traditional page unloads/refresh
            window.addEventListener('beforeunload', () => {
                EventController.state.isUnloading = true;
            });

            // Handle browser Back/Forward Cache (BFCache) restoration
            window.addEventListener('pageshow', (e) => {
                EventController.state.isUnloading = false; // Reset the unloading lock
                if (e.persisted) { // If the page was restored from browser memory
                    EventController.resetState(); // Force clear the progress bar instantly
                }
            });

            // Decoupled Reset Helper
            const handleNavigationReset = () => {
                // Wait briefly to see if beforeunload fires (indicating a non-SPA traditional reload)
                setTimeout(() => {
                    if (!EventController.state.isUnloading) {
                        // It's an SPA website: safely reset the visual bar and unfreeze paging state
                        EventController.resetState();
                    }
                    // For non-SPA websites, we do absolutely nothing.
                    // This keeps the bar 100% glowing and locked until the browser destroys the page.
                }, 150);
            };

            window.addEventListener('popstate', () => {
                handleNavigationReset();
                EventController.state.isBound = false;
                App.tryBindEvents();
                App.startObserver();
            });

            window.navigation?.addEventListener("navigate", () => {
                handleNavigationReset();
            });

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

    App.init();

})();