From 5c9e260ce19794ce25eabc49374cac43b2bba702 Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Sun, 8 Feb 2026 20:56:30 +0100 Subject: [PATCH] Fix playback performance on long texts --- CHANGELOG.md | 1 + src/App.tsx | 170 +++++++++++++++++++++++++++----------- src/components/TopBar.tsx | 19 ++++- src/lib/reading.ts | 39 +++++++++ src/lib/storage.ts | 130 +++++++++++++++++++++++++++-- 5 files changed, 303 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e8d37..488a5ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Pause "Real WPM" updates while paused, add an adaptive timing toggle, and make the WPM setting reflect actual reading throughput - Add `Shift+↑/↓` shortcuts for adjusting WPM by 100 and document them in the shortcuts modal - Improve mobile support for long words by shifting the focal point left +- Fix major performance issues on long texts by reducing frequent localStorage writes during playback and keeping global keyboard listeners stable ### 2026-02-07: 1.1.0 diff --git a/src/App.tsx b/src/App.tsx index d2ed172..985f053 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,18 +14,25 @@ import { SettingsModal } from "./components/modals/SettingsModal"; import { ShortcutsModal } from "./components/modals/ShortcutsModal"; import { parseEpub, fetchMetadataFromOpenLibrary } from "./lib/epub"; import { parsePdf } from "./lib/pdf"; -import { formatReadingTime, getORPIndex, getWordDelay } from "./lib/reading"; +import { + formatReadingTime, + getORPIndex, + getTimingMultiplier, + getWordDelayForWords, +} from "./lib/reading"; import { IOS_BANNER_DISMISSED_KEY, getPositionForText, isFullscreenSupported, isIOSSafari, loadSettings, - savePositionForText, - saveSettings, + savePositions, + savePrefs, + saveTextPayload, + setPositionForTextInPlace, } from "./lib/storage"; import { styles } from "./styles"; -import type { BookMetadata, PositionsMap, ReaderSettings } from "./types"; +import type { BookMetadata, PositionsMap } from "./types"; const DEFAULT_TEXT = `Welcome to the RSVP Speed Reader! This tool uses Rapid Serial Visual Presentation to help you read faster. Click the text icon in the top left to paste text or load an EPUB file. The reader displays one word at a time at a fixed focal point, reducing eye movement and allowing for faster reading speeds. Research suggests that RSVP can help readers achieve speeds of 500 words per minute or more with practice. Try starting at a comfortable pace and gradually increase the speed as you become more accustomed to the technique. Happy reading!`; @@ -104,17 +111,28 @@ export default function App() { }); const timeoutRef = useRef(null); + const positionSaveTimeoutRef = useRef(null); const prevTextRef = useRef(text); const fileInputRef = useRef(null); const [textEditorSession, setTextEditorSession] = useState(0); const wpmEventsRef = useRef>([]); const lastIndexRef = useRef(currentIndex); + const currentIndexRef = useRef(currentIndex); + const wordsLengthRef = useRef(words.length); const speedScaleRef = useRef(1); const observedWpmRef = useRef(null); const observedWpmEmaRef = useRef(null); const lastControlTickRef = useRef(null); const wasPlayingRef = useRef(false); + useEffect(() => { + currentIndexRef.current = currentIndex; + }, [currentIndex]); + + useEffect(() => { + wordsLengthRef.current = words.length; + }, [words.length]); + const computeWindowStats = useCallback((now: number, windowMs: number) => { const events = wpmEventsRef.current; if (events.length === 0) return null; @@ -148,12 +166,24 @@ export default function App() { }); }, []); + const toggleSettings = useCallback(() => { + setShowSettings((prev) => !prev); + }, []); + + const toggleShortcuts = useCallback(() => { + setShowShortcuts((prev) => !prev); + }, []); + + const toggleInfo = useCallback(() => { + setShowInfo((prev) => !prev); + }, []); + const togglePlay = useCallback(() => { - if (currentIndex >= words.length - 1) { - setCurrentIndex(0); - } + const idx = currentIndexRef.current; + const len = wordsLengthRef.current; + if (len > 0 && idx >= len - 1) setCurrentIndex(0); setIsPlaying((prev) => !prev); - }, [currentIndex, words.length]); + }, []); const reset = useCallback(() => { setIsPlaying(false); @@ -172,10 +202,29 @@ export default function App() { }, []); const stepWord = useCallback((delta: number) => { - setCurrentIndex((prev) => - Math.max(0, Math.min(words.length - 1, prev + delta)), - ); - }, [words.length]); + const len = wordsLengthRef.current; + setCurrentIndex((prev) => Math.max(0, Math.min(len - 1, prev + delta))); + }, []); + + const back10 = useCallback(() => { + stepWord(-10); + }, [stepWord]); + + const forward10 = useCallback(() => { + stepWord(10); + }, [stepWord]); + + const toggleFetchMetadataOnline = useCallback(() => { + setFetchMetadataOnline((prev) => !prev); + }, []); + + const toggleShowRealWpm = useCallback(() => { + setShowRealWpm((prev) => !prev); + }, []); + + const togglePunctuationPauses = useCallback(() => { + setPunctuationPauses((prev) => !prev); + }, []); const handleFileUpload = useCallback( async (e: ChangeEvent) => { @@ -250,37 +299,61 @@ export default function App() { } }, [text]); - // Save settings including position for current text. + // Persist position for current text, but avoid serializing large payloads while playing. useEffect(() => { - positionsRef.current = savePositionForText( - text, - currentIndex, - positionsRef.current, - ); - const next: ReaderSettings = { + setPositionForTextInPlace(text, currentIndex, positionsRef.current); + + // If not playing, persist immediately (seeking/stepping should feel durable). + if (!isPlaying) { + if (positionSaveTimeoutRef.current) { + window.clearTimeout(positionSaveTimeoutRef.current); + positionSaveTimeoutRef.current = null; + } + savePositions(positionsRef.current); + return; + } + + // While playing, batch frequent position updates to reduce main-thread stalls. + if (positionSaveTimeoutRef.current) return; + positionSaveTimeoutRef.current = window.setTimeout(() => { + positionSaveTimeoutRef.current = null; + savePositions(positionsRef.current); + }, 2000); + }, [currentIndex, isPlaying, text]); + + // Flush any pending position save when stopping playback. + useEffect(() => { + if (isPlaying) return; + if (!positionSaveTimeoutRef.current) return; + window.clearTimeout(positionSaveTimeoutRef.current); + positionSaveTimeoutRef.current = null; + savePositions(positionsRef.current); + }, [isPlaying]); + + // Save preferences separately (small payload). + useEffect(() => { + savePrefs({ wpm, - text, - positions: positionsRef.current, sideOpacity, wordAmount, - bookMetadata, fetchMetadataOnline, showRealWpm, punctuationPauses, - }; - saveSettings(next); + }); }, [ wpm, - text, - currentIndex, sideOpacity, wordAmount, - bookMetadata, fetchMetadataOnline, showRealWpm, punctuationPauses, ]); + // Save text + metadata only when they change (can be large). + useEffect(() => { + saveTextPayload({ text, bookMetadata }); + }, [bookMetadata, text]); + const getTargetStepDelayMs = useCallback(() => { // WPM is "real" words-per-minute, even when showing multiple words per display. return (60_000 * wordAmount) / wpm; @@ -301,7 +374,7 @@ export default function App() { const end = Math.min(idx + wordAmount, words.length); const chunk = words.slice(idx, end).join(" "); // getWordDelay uses baseDelay * multiplier; pass base=1 to get multiplier back. - const m = getWordDelay(chunk, 1, true); + const m = getTimingMultiplier(chunk, true); sum += m; count++; } @@ -340,9 +413,14 @@ export default function App() { useEffect(() => { if (isPlaying && words.length > 0 && currentIndex < words.length) { const endIndex = Math.min(currentIndex + wordAmount, words.length); - const chunk = words.slice(currentIndex, endIndex).join(" "); const baseDelay = getTargetStepDelayMs() * speedScaleRef.current; - const delay = getWordDelay(chunk, baseDelay, punctuationPauses); + const delay = getWordDelayForWords( + words, + currentIndex, + endIndex, + baseDelay, + punctuationPauses, + ); timeoutRef.current = window.setTimeout(() => { setCurrentIndex((prev) => { @@ -427,6 +505,7 @@ export default function App() { if (target && (target.tagName === "TEXTAREA" || target.tagName === "INPUT")) return; + const len = wordsLengthRef.current; switch (e.key) { case " ": e.preventDefault(); @@ -435,9 +514,9 @@ export default function App() { case "ArrowRight": e.preventDefault(); if (e.shiftKey) { - setCurrentIndex((prev) => Math.min(words.length - 1, prev + 10)); + setCurrentIndex((prev) => Math.min(len - 1, prev + 10)); } else { - setCurrentIndex((prev) => Math.min(words.length - 1, prev + 1)); + setCurrentIndex((prev) => Math.min(len - 1, prev + 1)); } break; case "ArrowLeft": @@ -474,7 +553,7 @@ export default function App() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [adjustWpm, reset, togglePlay, words.length]); + }, []); const getCurrentWords = useCallback(() => { if (words.length === 0) return ""; @@ -483,7 +562,8 @@ export default function App() { }, [currentIndex, wordAmount, words]); const copyPositionUrl = useCallback(async () => { - const url = `${window.location.origin}${window.location.pathname}#pos=${currentIndex}`; + const idx = currentIndexRef.current; + const url = `${window.location.origin}${window.location.pathname}#pos=${idx}`; try { await navigator.clipboard.writeText(url); setLinkCopied(true); @@ -491,7 +571,7 @@ export default function App() { } catch (e) { console.error("Failed to copy URL:", e); } - }, [currentIndex]); + }, []); const toggleFullscreen = useCallback(async () => { try { @@ -587,9 +667,9 @@ export default function App() { onAdjustWpm={adjustWpm} showRealWpm={showRealWpm} realWpm={realWpm} - onToggleSettings={() => setShowSettings((prev) => !prev)} - onToggleShortcuts={() => setShowShortcuts((prev) => !prev)} - onToggleInfo={() => setShowInfo((prev) => !prev)} + onToggleSettings={toggleSettings} + onToggleShortcuts={toggleShortcuts} + onToggleInfo={toggleInfo} /> {showTextInput && ( @@ -616,8 +696,8 @@ export default function App() { stepWord(-10)} - onForward10={() => stepWord(10)} + onBack10={back10} + onForward10={forward10} onProgressClick={handleProgressClick} onProgressKeyDown={handleProgressKeyDown} progressPercent={progressPercent} @@ -640,13 +720,9 @@ export default function App() { punctuationPauses={punctuationPauses} onChangeWordAmount={setWordAmount} onChangeSideOpacity={setSideOpacity} - onToggleFetchMetadataOnline={() => - setFetchMetadataOnline((prev) => !prev) - } - onToggleShowRealWpm={() => setShowRealWpm((prev) => !prev)} - onTogglePunctuationPauses={() => - setPunctuationPauses((prev) => !prev) - } + onToggleFetchMetadataOnline={toggleFetchMetadataOnline} + onToggleShowRealWpm={toggleShowRealWpm} + onTogglePunctuationPauses={togglePunctuationPauses} onClose={() => setShowSettings(false)} /> )} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 134ad5d..ab3138a 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,4 +1,5 @@ import { FileText, Info, Keyboard, Maximize, Minimize, Minus, Plus, Settings, Upload } from "lucide-react"; +import { useCallback } from "react"; import type { ChangeEventHandler, RefObject } from "react"; import { styles } from "../styles"; @@ -43,6 +44,18 @@ export function TopBar({ onToggleShortcuts, onToggleInfo, }: Props) { + const openFilePicker = useCallback(() => { + fileInputRef.current?.click(); + }, [fileInputRef]); + + const decWpm = useCallback(() => { + onAdjustWpm(-25); + }, [onAdjustWpm]); + + const incWpm = useCallback(() => { + onAdjustWpm(25); + }, [onAdjustWpm]); + return (
@@ -60,7 +73,7 @@ export function TopBar({