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