Fix playback performance on long texts

This commit is contained in:
2026-02-08 20:56:30 +01:00
parent fe1fa110ee
commit 5c9e260ce1
5 changed files with 303 additions and 56 deletions
+1
View File
@@ -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
+123 -47
View File
@@ -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<number | null>(null);
const positionSaveTimeoutRef = useRef<number | null>(null);
const prevTextRef = useRef(text);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [textEditorSession, setTextEditorSession] = useState(0);
const wpmEventsRef = useRef<Array<{ t: number; words: number }>>([]);
const lastIndexRef = useRef<number>(currentIndex);
const currentIndexRef = useRef<number>(currentIndex);
const wordsLengthRef = useRef<number>(words.length);
const speedScaleRef = useRef(1);
const observedWpmRef = useRef<number | null>(null);
const observedWpmEmaRef = useRef<number | null>(null);
const lastControlTickRef = useRef<number | null>(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<HTMLInputElement>) => {
@@ -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() {
<BottomControls
isPlaying={isPlaying}
onTogglePlay={togglePlay}
onBack10={() => 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)}
/>
)}
+16 -3
View File
@@ -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 (
<div style={styles.topBar} className="top-bar">
<div style={styles.topLeft}>
@@ -60,7 +73,7 @@ export function TopBar({
</button>
<button
onClick={() => fileInputRef.current?.click()}
onClick={openFilePicker}
style={styles.textBtn}
className="icon-btn"
title="Upload EPUB, PDF, or TXT"
@@ -96,7 +109,7 @@ export function TopBar({
<div style={styles.wpmStack}>
<div style={styles.wpmControl} className="wpm-control">
<button
onClick={() => onAdjustWpm(-25)}
onClick={decWpm}
style={styles.wpmBtn}
className="wpm-btn"
>
@@ -109,7 +122,7 @@ export function TopBar({
<span style={styles.wpmLabel}>WPM</span>
</div>
<button
onClick={() => onAdjustWpm(25)}
onClick={incWpm}
style={styles.wpmBtn}
className="wpm-btn"
>
+39
View File
@@ -36,6 +36,32 @@ export function getTimingMultiplier(text: string, adaptiveTiming: boolean): numb
return multiplier;
}
export function getTimingMultiplierForWords(
words: string[],
startIndex: number,
endIndex: number,
adaptiveTiming: boolean,
): number {
if (!adaptiveTiming) return 1;
if (endIndex <= startIndex) return 1;
let maxLen = 0;
for (let i = startIndex; i < endIndex; i++) {
const w = words[i];
if (w && w.length > maxLen) maxLen = w.length;
}
let multiplier = 1 + Math.sqrt(maxLen) * 0.04;
const lastToken = words[endIndex - 1] || "";
const last = stripTrailingClosers(lastToken);
if (/[.!?]$/.test(last)) {
multiplier = Math.max(multiplier, 2.5);
} else if (/[,;:]$/.test(last)) {
multiplier = Math.max(multiplier, 1.8);
}
return multiplier;
}
export function getWordDelay(
text: string,
baseDelayMs: number,
@@ -44,6 +70,19 @@ export function getWordDelay(
return baseDelayMs * getTimingMultiplier(text, adaptiveTiming);
}
export function getWordDelayForWords(
words: string[],
startIndex: number,
endIndex: number,
baseDelayMs: number,
adaptiveTiming: boolean,
): number {
return (
baseDelayMs *
getTimingMultiplierForWords(words, startIndex, endIndex, adaptiveTiming)
);
}
export function formatReadingTime(minutes: number): string {
minutes = Math.round(minutes);
if (minutes < 1) return "< 1 min";
+124 -6
View File
@@ -1,8 +1,14 @@
import type { PositionsMap, ReaderSettings } from "../types";
// Legacy combined storage key (prefs + text + positions).
export const STORAGE_KEY = "rsvp-reader-settings";
export const IOS_BANNER_DISMISSED_KEY = "rsvp-ios-banner-dismissed";
// New split keys: avoids serializing large text payloads on frequent updates.
const PREFS_KEY = "rsvp-reader-prefs";
const TEXT_KEY = "rsvp-reader-text";
const POSITIONS_KEY = "rsvp-reader-positions";
// Detect iOS Safari (not in standalone mode).
export function isIOSSafari(): boolean {
const ua = navigator.userAgent;
@@ -31,7 +37,72 @@ function hashText(text: string): string {
return hash.toString();
}
type ReaderPrefs = Omit<ReaderSettings, "text" | "positions" | "bookMetadata">;
type TextPayload = Pick<ReaderSettings, "text" | "bookMetadata">;
function loadJson<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
return parsed as T;
} catch {
return null;
}
}
function saveJson(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error("Failed to save settings:", e);
}
}
export function loadSettings(): ReaderSettings | null {
// Prefer split storage.
const prefs = loadJson<Partial<ReaderPrefs>>(PREFS_KEY);
const textPayload = loadJson<Partial<TextPayload>>(TEXT_KEY);
const positions = loadJson<PositionsMap>(POSITIONS_KEY);
if (prefs || textPayload || positions) {
// Keep validation light to avoid breaking older stored versions.
const wpm = typeof prefs?.wpm === "number" ? prefs.wpm : 300;
const sideOpacity =
typeof prefs?.sideOpacity === "number" ? prefs.sideOpacity : 0.5;
const wordAmount =
typeof prefs?.wordAmount === "number" ? prefs.wordAmount : 1;
const fetchMetadataOnline =
typeof prefs?.fetchMetadataOnline === "boolean"
? prefs.fetchMetadataOnline
: false;
const showRealWpm =
typeof prefs?.showRealWpm === "boolean" ? prefs.showRealWpm : false;
const punctuationPauses =
typeof prefs?.punctuationPauses === "boolean"
? prefs.punctuationPauses
: true;
const text = typeof textPayload?.text === "string" ? textPayload.text : "";
const bookMetadata =
textPayload && "bookMetadata" in textPayload
? (textPayload.bookMetadata ?? null)
: null;
return {
wpm,
text,
positions: positions || {},
sideOpacity,
wordAmount,
bookMetadata,
fetchMetadataOnline,
showRealWpm,
punctuationPauses,
};
}
// Fallback to legacy combined storage.
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return null;
@@ -39,7 +110,16 @@ export function loadSettings(): ReaderSettings | null {
const parsed: unknown = JSON.parse(saved);
// Keep validation light to avoid breaking older stored versions.
if (!parsed || typeof parsed !== "object") return null;
return parsed as ReaderSettings;
const legacy = parsed as ReaderSettings;
// One-time migration best effort.
saveSettings(legacy);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore removal errors.
}
return legacy;
} catch (e) {
console.error("Failed to load settings:", e);
return null;
@@ -47,11 +127,16 @@ export function loadSettings(): ReaderSettings | null {
}
export function saveSettings(settings: ReaderSettings): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (e) {
console.error("Failed to save settings:", e);
}
savePrefs({
wpm: settings.wpm,
sideOpacity: settings.sideOpacity,
wordAmount: settings.wordAmount,
fetchMetadataOnline: settings.fetchMetadataOnline,
showRealWpm: settings.showRealWpm,
punctuationPauses: settings.punctuationPauses,
});
saveTextPayload({ text: settings.text, bookMetadata: settings.bookMetadata });
savePositions(settings.positions);
}
export function getPositionForText(text: string, positions?: PositionsMap): number {
@@ -59,6 +144,16 @@ export function getPositionForText(text: string, positions?: PositionsMap): numb
return positions?.[hash] ?? 0;
}
// Mutating variant to avoid allocating a new positions object on each update.
export function setPositionForTextInPlace(
text: string,
position: number,
positions: PositionsMap,
): void {
const hash = hashText(text);
positions[hash] = position;
}
export function savePositionForText(
text: string,
position: number,
@@ -68,3 +163,26 @@ export function savePositionForText(
return { ...positions, [hash]: position };
}
export function loadPrefs(): Partial<ReaderPrefs> | null {
return loadJson<Partial<ReaderPrefs>>(PREFS_KEY);
}
export function savePrefs(prefs: ReaderPrefs): void {
saveJson(PREFS_KEY, prefs);
}
export function loadTextPayload(): Partial<TextPayload> | null {
return loadJson<Partial<TextPayload>>(TEXT_KEY);
}
export function saveTextPayload(payload: TextPayload): void {
saveJson(TEXT_KEY, payload);
}
export function loadPositions(): PositionsMap | null {
return loadJson<PositionsMap>(POSITIONS_KEY);
}
export function savePositions(positions: PositionsMap): void {
saveJson(POSITIONS_KEY, positions);
}