WTR-Lab Auto Scroller

Adds dual-mode auto-scrolling (Constant Speed or Dynamic WPM) to the WTR-Lab reader for a hands-free reading experience.

// ==UserScript==
// @name         WTR-Lab Auto Scroller
// @namespace    http://tampermonkey.net/
// @version      4.1
// @description  Adds dual-mode auto-scrolling (Constant Speed or Dynamic WPM) to the WTR-Lab reader for a hands-free reading experience.
// @author       MasuRii
// @match        https://wtr-lab.com/en/novel/*/*/chapter-*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wtr-lab.com
// @license      MIT
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration & State ---
    const STORAGE_KEY = 'wtrLabAutoScrollSettings_v3.8';
    const LOGGING_STORAGE_KEY = 'wtrLabAutoScrollSettings_loggingEnabled_v3.8';
    const HIGHLIGHT_CLASS = 'wtr-lab-as-highlight';
    const CHAPTER_BODY_SELECTOR = '.chapter-tracker.active .chapter-body'; // <<< FIX: Targeted the active chapter's body
    const MIN_READING_TIME_MS = 750;

    const DEFAULT_SETTINGS = {
        speed: 1.00,
        wpm: 230,
        mode: 'constant',
        isAutoScrollEnabled: false,
        highlightingEnabled: true
    };

    // --- State Variables ---
    let settings;
    let playButton = null;
    let isProgrammaticScroll = false;
    let programmaticScrollTimeout = null;
    let currentlyHighlightedParagraph = null;
    let loggingEnabled = GM_getValue(LOGGING_STORAGE_KEY, false);
    let constantScrollIntervalId = null;
    const dynamicScrollState = {
        isRunning: false,
        currentParagraphIndex: 0,
        startIndex: 0,
        animationFrameId: null,
        stopRequested: false,
        allParagraphs: []
    };
    let wakeLockSentinel = null;
    let lastUrl = window.location.href;

    // --- Tampermonkey Menu Commands ---
    function toggleLogging() {
        loggingEnabled = !loggingEnabled;
        GM_setValue(LOGGING_STORAGE_KEY, loggingEnabled);
        alert(`Debug logging is now ${loggingEnabled ? 'ENABLED' : 'DISABLED'}.`);
    }
    GM_registerMenuCommand('Toggle Debug Logging', toggleLogging);

    // --- Initialization & Settings ---
    try {
        const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
        settings = { ...DEFAULT_SETTINGS, ...saved };
    } catch (e) {
        settings = { ...DEFAULT_SETTINGS };
    }

    function saveSettings() {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
    }

    function log(message, ...args) {
        if (loggingEnabled) {
            console.log(`[AutoScroller] ${message}`, ...args);
        }
    }

    // --- UI Management ---
    function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
        /* --- Highlight Style --- */
        .${HIGHLIGHT_CLASS} {
            background-color: rgba(255, 255, 0, 0.15);
            border-left: 3px solid var(--bs-primary, #fd7e14);
            border-radius: 4px;
            padding-left: 10px !important;
            margin-left: -13px !important;
            transition: background-color 0.3s, border-left 0.3s;
        }
        body.dark-mode .${HIGHLIGHT_CLASS} {
            background-color: rgba(255, 255, 255, 0.1) !important;
        }

        /* --- UI Control Styles --- */
        #auto-scroll-speed-group, #auto-scroll-wpm-group {
            flex-wrap: nowrap !important; /* Prevents wrapping inside the input groups */
            width: auto !important;      /* CRITICAL FIX: Overrides the site's 100% width rule */
        }
        .wtr-as-switch {
            font-size: 0.8rem;
            color: var(--bs-secondary-color);
            white-space: nowrap;
        }
        .wtr-as-switch .form-check-label {
            line-height: 1.1;
            text-align: center;
        }
        .wtr-as-switch.disabled-custom {
            opacity: 0.5;
            pointer-events: none;
        }
        .wtr-as-input-wrapper {
            position: relative;
            display: flex;
            align-items: center;
        }
        .wtr-as-input-wrapper .form-control {
            padding-right: 48px !important;
        }
        .wtr-as-input-label {
            position: absolute;
            right: 10px;
            pointer-events: none;
            color: var(--bs-secondary-color);
            font-size: 0.9em;
        }
    `;
        document.head.appendChild(style);
    }

    function updatePlayButtonUI(isPlaying) {
        if (!playButton) return;
        if (isPlaying) {
            playButton.textContent = 'Stop';
            playButton.classList.remove('btn-outline-secondary');
            playButton.classList.add('btn-danger');
        } else {
            playButton.textContent = 'Play';
            playButton.classList.remove('btn-danger');
            playButton.classList.add('btn-outline-secondary');
        }
    }

    // --- Wake Lock ---
    async function requestWakeLock() {
        if ('wakeLock' in navigator && wakeLockSentinel === null) {
            try {
                wakeLockSentinel = await navigator.wakeLock.request('screen');
                wakeLockSentinel.addEventListener('release', () => { wakeLockSentinel = null; });
            } catch (err) { console.error(`Wake Lock Error: ${err.name}, ${err.message}`); }
        }
    }

    async function releaseWakeLock() {
        if (wakeLockSentinel !== null) {
            await wakeLockSentinel.release();
            wakeLockSentinel = null;
        }
    }

    // --- CORE SCROLLING LOGIC (ROUTERS) ---
    function startScrolling() {
        if (settings.mode === 'dynamic') {
            startDynamicScrolling();
        } else {
            startConstantScrolling();
        }
    }

    function stopScrolling(userInitiated = false) {
        stopConstantScrolling();
        stopDynamicScrolling();
        releaseWakeLock();
        updatePlayButtonUI(false);
        if (userInitiated && settings.isAutoScrollEnabled) {
            settings.isAutoScrollEnabled = false;
            saveSettings();
        }
        log('Auto-Scroll Stopped.');
    }

    function performProgrammaticScroll(scrollFunc) {
        if (programmaticScrollTimeout) clearTimeout(programmaticScrollTimeout);
        isProgrammaticScroll = true;
        scrollFunc();
        programmaticScrollTimeout = setTimeout(() => {
            isProgrammaticScroll = false;
        }, 150);
    }

    // --- DYNAMIC (WPM) SCROLLING ENGINE ---
    function smoothScrollTo(targetPosition, duration, onComplete) {
        const startPosition = window.scrollY;
        const distance = targetPosition - startPosition;
        let startTime = null;
        function animation(currentTime) {
            if (dynamicScrollState.stopRequested) return;
            if (startTime === null) startTime = currentTime;
            const timeElapsed = currentTime - startTime;
            const t = Math.min(timeElapsed / duration, 1);
            const easedT = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
            performProgrammaticScroll(() => {
                window.scrollTo(0, startPosition + distance * easedT);
            });
            if (timeElapsed < duration) {
                dynamicScrollState.animationFrameId = requestAnimationFrame(animation);
            } else {
                onComplete();
            }
        }
        dynamicScrollState.animationFrameId = requestAnimationFrame(animation);
    }

    function processNextParagraph() {
        if (dynamicScrollState.stopRequested || dynamicScrollState.currentParagraphIndex >= dynamicScrollState.allParagraphs.length) {
            stopDynamicScrolling();
            if (dynamicScrollState.currentParagraphIndex >= dynamicScrollState.allParagraphs.length) {
                releaseWakeLock();
                updatePlayButtonUI(false);
            }
            return;
        }
        if (settings.highlightingEnabled && currentlyHighlightedParagraph) {
            currentlyHighlightedParagraph.classList.remove(HIGHLIGHT_CLASS);
        }
        const p = dynamicScrollState.allParagraphs[dynamicScrollState.currentParagraphIndex];
        const text = p.textContent.trim();
        const wordCount = text.split(/\s+/).filter(Boolean).length;
        if (settings.highlightingEnabled) {
            p.classList.add(HIGHLIGHT_CLASS);
            currentlyHighlightedParagraph = p;
        }
        if (wordCount === 0) {
            dynamicScrollState.currentParagraphIndex++;
            setTimeout(processNextParagraph, 0);
            return;
        }
        const readingTimeMs = Math.max(MIN_READING_TIME_MS, (wordCount / settings.wpm) * 60 * 1000);
        const viewportTopOffset = window.innerHeight * 0.2;
        const targetY = p.offsetTop - viewportTopOffset;
        const isFirstParagraphToProcess = dynamicScrollState.currentParagraphIndex === dynamicScrollState.startIndex;
        if (!isFirstParagraphToProcess && targetY < window.scrollY + 5) {
            log(`Skipping paragraph ${dynamicScrollState.currentParagraphIndex} (already passed).`);
            dynamicScrollState.currentParagraphIndex++;
            setTimeout(processNextParagraph, 0);
            return;
        }
        log(`Scrolling to paragraph ${dynamicScrollState.currentParagraphIndex} (${wordCount} words) over ${readingTimeMs.toFixed(0)}ms.`);
        smoothScrollTo(targetY, readingTimeMs, () => {
            dynamicScrollState.currentParagraphIndex++;
            processNextParagraph();
        });
    }

    function startDynamicScrolling() {
        if (dynamicScrollState.isRunning) return;
        log('Starting Dynamic (WPM) Scroll at', settings.wpm, 'WPM.');
        const chapterBody = document.querySelector(CHAPTER_BODY_SELECTOR);
        if (!chapterBody) { log('Dynamic Scroll: Chapter body not found.'); return; }
        dynamicScrollState.isRunning = true;
        dynamicScrollState.stopRequested = false;
        dynamicScrollState.allParagraphs = Array.from(chapterBody.querySelectorAll('p'));
        if (dynamicScrollState.allParagraphs.length === 0) {
            log('Dynamic Scroll: No paragraphs found.');
            stopDynamicScrolling();
            return;
        }
        let startIndex = 0;
        if (window.scrollY > 50) {
            for (let i = 0; i < dynamicScrollState.allParagraphs.length; i++) {
                const rect = dynamicScrollState.allParagraphs[i].getBoundingClientRect();
                if (rect.bottom > 0) {
                    startIndex = i;
                    break;
                }
            }
        }
        dynamicScrollState.startIndex = startIndex;
        dynamicScrollState.currentParagraphIndex = startIndex;
        log(`Starting from paragraph index: ${startIndex}`);
        updatePlayButtonUI(true);
        requestWakeLock();
        processNextParagraph();
    }

    function stopDynamicScrolling() {
        if (!dynamicScrollState.isRunning) return;
        dynamicScrollState.stopRequested = true;
        if (dynamicScrollState.animationFrameId) {
            cancelAnimationFrame(dynamicScrollState.animationFrameId);
        }
        if (settings.highlightingEnabled && currentlyHighlightedParagraph) {
            currentlyHighlightedParagraph.classList.remove(HIGHLIGHT_CLASS);
            currentlyHighlightedParagraph = null;
        }
        dynamicScrollState.isRunning = false;
        dynamicScrollState.animationFrameId = null;
    }

    // --- CONSTANT (PIXEL) SCROLLING ENGINE ---
    function startConstantScrolling() {
        if (constantScrollIntervalId !== null) return;
        log('Starting Constant Scroll at speed multiplier:', settings.speed);
        updatePlayButtonUI(true);
        requestWakeLock();
        const basePixelsPerSecond = 50;
        const pixelsPerSecond = basePixelsPerSecond * settings.speed;
        const scrollDelay = Math.floor(1000 / pixelsPerSecond);
        constantScrollIntervalId = setInterval(() => {
            const atBottom = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 5;
            if (atBottom) {
                stopConstantScrolling();
                releaseWakeLock();
                updatePlayButtonUI(false);
                return;
            }
            performProgrammaticScroll(() => {
                window.scrollBy(0, 1);
            });
        }, scrollDelay);
    }

    function stopConstantScrolling() {
        if (constantScrollIntervalId === null) return;
        clearInterval(constantScrollIntervalId);
        constantScrollIntervalId = null;
    }

    // --- Event Handlers ---
    function handleUserScroll() {
        if (isProgrammaticScroll) return;
        const isScrolling = constantScrollIntervalId !== null || dynamicScrollState.isRunning;
        if (isScrolling) {
            stopScrolling(true);
        }
    }

    function handlePageNavigation() {
        if (window.location.href !== lastUrl) {
            lastUrl = window.location.href;
            log('New page detected by URL change.');
            stopScrolling();
            if (settings.isAutoScrollEnabled) {
                setTimeout(startScrolling, 750);
            }
        }
    }

    async function handleVisibilityChange() {
        const isScrolling = constantScrollIntervalId !== null || dynamicScrollState.isRunning;
        if (document.visibilityState === 'visible' && isScrolling) {
            await requestWakeLock();
        }
    }

    // --- UI CREATION ---
    function setupAutoReadControls() {
        if (document.getElementById('auto-read-controls-wrapper')) return;
        let readerTypeButtonGroup = null;
        const allSpans = document.querySelectorAll('.display-config span.config');
        for (const span of allSpans) {
            if (span.textContent.trim() === 'Reader Type') {
                readerTypeButtonGroup = span.nextElementSibling;
                break;
            }
        }
        if (!readerTypeButtonGroup) return;

        const mainLabel = document.createElement('span');
        mainLabel.className = 'config';
        mainLabel.textContent = 'Auto-Scroll Controls';

        const wrapper = document.createElement('div');
        wrapper.id = 'auto-read-controls-wrapper';
        wrapper.className = 'd-flex align-items-center gap-3 mb-1';

        // --- Element 1: Play/Stop Button ---
        playButton = document.createElement('button');
        playButton.type = 'button';
        playButton.className = 'btn btn-sm';
        playButton.title = 'Play/Stop Auto-Scroll';

        // --- Element 2: Speed/WPM Controls ---
        const speedGroup = document.createElement('div');
        speedGroup.id = 'auto-scroll-speed-group';
        speedGroup.className = 'input-group input-group-sm';
        const speedMinus = document.createElement('button');
        speedMinus.type = 'button';
        speedMinus.className = 'btn btn-outline-secondary';
        speedMinus.textContent = '-';
        const speedInputWrapper = document.createElement('div');
        speedInputWrapper.className = 'wtr-as-input-wrapper';
        const speedInput = document.createElement('input');
        speedInput.type = 'number';
        speedInput.className = 'form-control text-center';
        speedInput.step = '0.05';
        speedInput.min = '0.1';
        speedInput.title = 'Speed Multiplier';
        speedInput.style.cssText = 'background-color: transparent; color: inherit; min-width: 90px;';
        speedInput.value = parseFloat(settings.speed).toFixed(2);
        const speedLabel = document.createElement('span');
        speedLabel.className = 'wtr-as-input-label';
        speedLabel.textContent = 'SPD';
        speedInputWrapper.append(speedInput, speedLabel);
        const speedPlus = document.createElement('button');
        speedPlus.type = 'button';
        speedPlus.className = 'btn btn-outline-secondary';
        speedPlus.textContent = '+';
        speedGroup.append(speedMinus, speedInputWrapper, speedPlus);

        const wpmGroup = document.createElement('div');
        wpmGroup.id = 'auto-scroll-wpm-group';
        wpmGroup.className = 'input-group input-group-sm';
        const wpmMinus = document.createElement('button');
        wpmMinus.type = 'button';
        wpmMinus.className = 'btn btn-outline-secondary';
        wpmMinus.textContent = '-';
        const wpmInputWrapper = document.createElement('div');
        wpmInputWrapper.className = 'wtr-as-input-wrapper';
        const wpmInput = document.createElement('input');
        wpmInput.type = 'number';
        wpmInput.className = 'form-control text-center';
        wpmInput.step = '5';
        wpmInput.min = '50';
        wpmInput.title = 'Words Per Minute (WPM)';
        wpmInput.style.cssText = 'background-color: transparent; color: inherit; min-width: 90px;';
        wpmInput.value = settings.wpm;
        const wpmLabel = document.createElement('span');
        wpmLabel.className = 'wtr-as-input-label';
        wpmLabel.textContent = 'WPM';
        wpmInputWrapper.append(wpmInput, wpmLabel);
        const wpmPlus = document.createElement('button');
        wpmPlus.type = 'button';
        wpmPlus.className = 'btn btn-outline-secondary';
        wpmPlus.textContent = '+';
        wpmGroup.append(wpmMinus, wpmInputWrapper, wpmPlus);

        // --- Element 3: Paragraph Highlighting Switch ---
        const highlightSwitchWrapper = document.createElement('div');
        highlightSwitchWrapper.className = 'form-check form-switch d-flex align-items-center wtr-as-switch';
        highlightSwitchWrapper.title = 'Toggle paragraph highlighting';
        const highlightSwitchInput = document.createElement('input');
        highlightSwitchInput.type = 'checkbox';
        highlightSwitchInput.className = 'form-check-input';
        highlightSwitchInput.id = 'highlight-toggle-switch';
        highlightSwitchInput.role = 'switch';
        highlightSwitchInput.checked = settings.highlightingEnabled;
        const highlightSwitchLabel = document.createElement('label');
        highlightSwitchLabel.className = 'form-check-label ms-1';
        highlightSwitchLabel.htmlFor = 'highlight-toggle-switch';
        highlightSwitchLabel.innerHTML = 'Highlight<br>Para.';
        highlightSwitchWrapper.append(highlightSwitchInput, highlightSwitchLabel);

        // --- Element 4: Constant/Dynamic Mode Selector ---
        const modeGroup = document.createElement('div');
        modeGroup.className = 'btn-group btn-group-sm';
        modeGroup.role = 'group';
        const constantModeBtn = document.createElement('button');
        constantModeBtn.type = 'button';
        constantModeBtn.className = 'btn';
        constantModeBtn.textContent = 'Constant';
        const dynamicModeBtn = document.createElement('button');
        dynamicModeBtn.type = 'button';
        dynamicModeBtn.className = 'btn';
        dynamicModeBtn.textContent = 'Dynamic';
        modeGroup.append(constantModeBtn, dynamicModeBtn);

        // --- Logic for showing/hiding elements ---
        function updateControlsVisibility() {
            const isConstant = settings.mode === 'constant';
            speedGroup.style.display = isConstant ? 'flex' : 'none';
            wpmGroup.style.display = isConstant ? 'none' : 'flex';
            highlightSwitchWrapper.style.display = isConstant ? 'none' : 'flex';
            highlightSwitchInput.disabled = isConstant;
            highlightSwitchWrapper.classList.toggle('disabled-custom', isConstant);
            constantModeBtn.classList.toggle('btn-primary', isConstant);
            constantModeBtn.classList.toggle('btn-outline-secondary', !isConstant);
            dynamicModeBtn.classList.toggle('btn-primary', !isConstant);
            dynamicModeBtn.classList.toggle('btn-outline-secondary', isConstant);
        }

        // --- Event Listeners ---
        function switchMode(newMode) {
            if (settings.mode === newMode) return;
            const wasPlaying = constantScrollIntervalId !== null || dynamicScrollState.isRunning;
            if (wasPlaying) stopScrolling();
            settings.mode = newMode;
            saveSettings();
            updateControlsVisibility();
            if (wasPlaying) startScrolling();
        }
        constantModeBtn.addEventListener('click', () => switchMode('constant'));
        dynamicModeBtn.addEventListener('click', () => switchMode('dynamic'));
        playButton.addEventListener('click', () => {
            settings.isAutoScrollEnabled = !settings.isAutoScrollEnabled;
            saveSettings();
            if (settings.isAutoScrollEnabled) {
                startScrolling();
            } else {
                stopScrolling(true);
            }
        });
        highlightSwitchInput.addEventListener('change', () => {
            settings.highlightingEnabled = highlightSwitchInput.checked;
            saveSettings();
            if (!settings.highlightingEnabled && currentlyHighlightedParagraph) {
                currentlyHighlightedParagraph.classList.remove(HIGHLIGHT_CLASS);
                currentlyHighlightedParagraph = null;
            }
        });
        speedPlus.addEventListener('click', () => {
            speedInput.value = (parseFloat(speedInput.value) + 0.05).toFixed(2);
            settings.speed = parseFloat(speedInput.value);
            saveSettings();
            if (constantScrollIntervalId) { stopConstantScrolling(); startConstantScrolling(); }
        });
        speedMinus.addEventListener('click', () => {
            speedInput.value = Math.max(0.1, parseFloat(speedInput.value) - 0.05).toFixed(2);
            settings.speed = parseFloat(speedInput.value);
            saveSettings();
            if (constantScrollIntervalId) { stopConstantScrolling(); startConstantScrolling(); }
        });
        wpmPlus.addEventListener('click', () => {
            wpmInput.value = parseInt(wpmInput.value, 10) + 5;
            settings.wpm = parseInt(wpmInput.value, 10);
            saveSettings();
        });
        wpmMinus.addEventListener('click', () => {
            wpmInput.value = Math.max(50, parseInt(wpmInput.value, 10) - 5);
            settings.wpm = parseInt(wpmInput.value, 10);
            saveSettings();
        });

        // --- Assemble and Inject UI ---
        wrapper.append(playButton, speedGroup, wpmGroup, highlightSwitchWrapper, modeGroup);
        readerTypeButtonGroup.after(mainLabel, wrapper);
        updatePlayButtonUI(false);
        updateControlsVisibility();
        if (settings.isAutoScrollEnabled) {
            startScrolling();
        }
        log('Auto-Scroll controls added successfully.');
    }

    // --- Observers and Initializers ---
    injectStyles();
    window.addEventListener('scroll', handleUserScroll, { passive: true });
    document.addEventListener('visibilitychange', handleVisibilityChange);
    setInterval(handlePageNavigation, 500);
    const observer = new MutationObserver(() => {
        if (!document.getElementById('auto-read-controls-wrapper') && document.querySelector('.display-config')) {
            setupAutoReadControls();
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

})();