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              3.1.5
// @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/*
// @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;" }
        ],

        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 ? 150 : 0;

            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 ? 150 : 0;

            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 {
                if (!Store.getSetting('accumulateMode')) {
                    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;
            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.touchAccumulated = 0;
                }
            }
            EventController.state.lastTouchY = touchCurrentY;
        },

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

        bindMouse: () => {
            // 安全機制:先解綁舊的監聽器,再重新綁定,防止在 BFCache / SPA 歷程中堆疊
            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.addEventListener("touchstart", EventController.handleTouchStart, { passive: true });
            document.addEventListener("touchmove", EventController.handleTouchMove, { passive: false });
            document.addEventListener("touchend", 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();

})();