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.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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

})();