Add real WPM and honest adaptive timing
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
|
### 2026-02-08: 1.1.1
|
||||||
|
|
||||||
|
- Add an optional "Real WPM" indicator beneath the WPM control (computed from the last 60 seconds of reading)
|
||||||
|
- Pause "Real WPM" updates while paused, add an adaptive timing toggle, and make the WPM setting reflect actual reading throughput
|
||||||
|
|
||||||
### 2026-02-07: 1.1.0
|
### 2026-02-07: 1.1.0
|
||||||
|
|
||||||
- Split the big app file into smaller TypeScript modules and add `tsc` typechecking
|
- Split the big app file into smaller TypeScript modules and add `tsc` typechecking
|
||||||
|
|||||||
+197
-12
@@ -85,6 +85,13 @@ export default function App() {
|
|||||||
const [fetchMetadataOnline, setFetchMetadataOnline] = useState(
|
const [fetchMetadataOnline, setFetchMetadataOnline] = useState(
|
||||||
() => savedSettings?.fetchMetadataOnline ?? false,
|
() => savedSettings?.fetchMetadataOnline ?? false,
|
||||||
);
|
);
|
||||||
|
const [showRealWpm, setShowRealWpm] = useState(
|
||||||
|
() => savedSettings?.showRealWpm ?? false,
|
||||||
|
);
|
||||||
|
const [punctuationPauses, setPunctuationPauses] = useState(
|
||||||
|
() => savedSettings?.punctuationPauses ?? true,
|
||||||
|
);
|
||||||
|
const [realWpm, setRealWpm] = useState<number | null>(null);
|
||||||
const [linkCopied, setLinkCopied] = useState(false);
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [showIOSBanner, setShowIOSBanner] = useState(() => {
|
const [showIOSBanner, setShowIOSBanner] = useState(() => {
|
||||||
@@ -100,6 +107,38 @@ export default function App() {
|
|||||||
const prevTextRef = useRef(text);
|
const prevTextRef = useRef(text);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [textEditorSession, setTextEditorSession] = useState(0);
|
const [textEditorSession, setTextEditorSession] = useState(0);
|
||||||
|
const wpmEventsRef = useRef<Array<{ t: number; words: number }>>([]);
|
||||||
|
const lastIndexRef = useRef<number>(currentIndex);
|
||||||
|
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);
|
||||||
|
|
||||||
|
const computeWindowStats = useCallback((now: number, windowMs: number) => {
|
||||||
|
const events = wpmEventsRef.current;
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
|
const cutoff = now - windowMs;
|
||||||
|
let first = 0;
|
||||||
|
while (first < events.length && events[first].t < cutoff) first++;
|
||||||
|
if (first >= events.length) return null;
|
||||||
|
|
||||||
|
const spanMs = Math.max(1, now - events[first].t);
|
||||||
|
let wordsRead = 0;
|
||||||
|
for (let i = first; i < events.length; i++) wordsRead += events[i].words;
|
||||||
|
const wpmNow = Math.max(0, Math.round((wordsRead / (spanMs / 1000)) * 60));
|
||||||
|
|
||||||
|
return { wpm: wpmNow, wordsRead, spanMs };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pruneWpmEvents = useCallback((now: number, keepMs: number) => {
|
||||||
|
const cutoff = now - keepMs;
|
||||||
|
const events = wpmEventsRef.current;
|
||||||
|
let first = 0;
|
||||||
|
while (first < events.length && events[first].t < cutoff) first++;
|
||||||
|
if (first > 0) wpmEventsRef.current = events.slice(first);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleTextInput = useCallback(() => {
|
const toggleTextInput = useCallback(() => {
|
||||||
setShowTextInput((prev) => {
|
setShowTextInput((prev) => {
|
||||||
@@ -119,6 +158,13 @@ export default function App() {
|
|||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
|
wpmEventsRef.current = [];
|
||||||
|
lastIndexRef.current = 0;
|
||||||
|
speedScaleRef.current = 1;
|
||||||
|
observedWpmRef.current = null;
|
||||||
|
observedWpmEmaRef.current = null;
|
||||||
|
lastControlTickRef.current = null;
|
||||||
|
setRealWpm(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const adjustWpm = useCallback((delta: number) => {
|
const adjustWpm = useCallback((delta: number) => {
|
||||||
@@ -187,11 +233,20 @@ export default function App() {
|
|||||||
setWords(parsed);
|
setWords(parsed);
|
||||||
// Text changed, load position for new text.
|
// Text changed, load position for new text.
|
||||||
const savedPos = getPositionForText(text, positionsRef.current);
|
const savedPos = getPositionForText(text, positionsRef.current);
|
||||||
setCurrentIndex(
|
const nextIndex = Math.min(
|
||||||
Math.min(Math.max(0, savedPos), Math.max(0, parsed.length - 1)),
|
Math.max(0, savedPos),
|
||||||
|
Math.max(0, parsed.length - 1),
|
||||||
);
|
);
|
||||||
|
setCurrentIndex(nextIndex);
|
||||||
prevTextRef.current = text;
|
prevTextRef.current = text;
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
wpmEventsRef.current = [];
|
||||||
|
lastIndexRef.current = nextIndex;
|
||||||
|
speedScaleRef.current = 1;
|
||||||
|
observedWpmRef.current = null;
|
||||||
|
observedWpmEmaRef.current = null;
|
||||||
|
lastControlTickRef.current = null;
|
||||||
|
setRealWpm(null);
|
||||||
}
|
}
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
@@ -210,6 +265,8 @@ export default function App() {
|
|||||||
wordAmount,
|
wordAmount,
|
||||||
bookMetadata,
|
bookMetadata,
|
||||||
fetchMetadataOnline,
|
fetchMetadataOnline,
|
||||||
|
showRealWpm,
|
||||||
|
punctuationPauses,
|
||||||
};
|
};
|
||||||
saveSettings(next);
|
saveSettings(next);
|
||||||
}, [
|
}, [
|
||||||
@@ -220,16 +277,72 @@ export default function App() {
|
|||||||
wordAmount,
|
wordAmount,
|
||||||
bookMetadata,
|
bookMetadata,
|
||||||
fetchMetadataOnline,
|
fetchMetadataOnline,
|
||||||
|
showRealWpm,
|
||||||
|
punctuationPauses,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getBaseDelay = useCallback(() => {
|
const getTargetStepDelayMs = useCallback(() => {
|
||||||
return (60 / wpm) * 1000;
|
// WPM is "real" words-per-minute, even when showing multiple words per display.
|
||||||
}, [wpm]);
|
return (60_000 * wordAmount) / wpm;
|
||||||
|
}, [wpm, wordAmount]);
|
||||||
|
|
||||||
|
const estimateAverageMultiplier = useCallback(
|
||||||
|
(startIndex: number) => {
|
||||||
|
if (!punctuationPauses) return 1;
|
||||||
|
if (words.length === 0) return 1;
|
||||||
|
|
||||||
|
// Look ahead a bit to estimate typical slowdown so we can seed the scale quickly.
|
||||||
|
const steps = 120;
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const idx = startIndex + i * wordAmount;
|
||||||
|
if (idx >= words.length) break;
|
||||||
|
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);
|
||||||
|
sum += m;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count === 0 ? 1 : sum / count;
|
||||||
|
},
|
||||||
|
[punctuationPauses, wordAmount, words],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed/Reset the scaling factor when starting playback or when timing settings change.
|
||||||
|
useEffect(() => {
|
||||||
|
const startedPlaying = isPlaying && !wasPlayingRef.current;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
if (!startedPlaying) return;
|
||||||
|
|
||||||
|
const avgMult = estimateAverageMultiplier(currentIndex);
|
||||||
|
speedScaleRef.current = avgMult > 0 ? 1 / avgMult : 1;
|
||||||
|
wpmEventsRef.current = [];
|
||||||
|
observedWpmRef.current = null;
|
||||||
|
observedWpmEmaRef.current = null;
|
||||||
|
lastControlTickRef.current = null;
|
||||||
|
if (showRealWpm) setRealWpm(null);
|
||||||
|
}, [currentIndex, estimateAverageMultiplier, isPlaying, showRealWpm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
const idx = lastIndexRef.current;
|
||||||
|
const avgMult = estimateAverageMultiplier(idx);
|
||||||
|
speedScaleRef.current = avgMult > 0 ? 1 / avgMult : 1;
|
||||||
|
wpmEventsRef.current = [];
|
||||||
|
observedWpmRef.current = null;
|
||||||
|
observedWpmEmaRef.current = null;
|
||||||
|
lastControlTickRef.current = null;
|
||||||
|
if (showRealWpm) setRealWpm(null);
|
||||||
|
}, [estimateAverageMultiplier, isPlaying, punctuationPauses, showRealWpm, wordAmount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPlaying && words.length > 0 && currentIndex < words.length) {
|
if (isPlaying && words.length > 0 && currentIndex < words.length) {
|
||||||
const currentWord = words[currentIndex];
|
const endIndex = Math.min(currentIndex + wordAmount, words.length);
|
||||||
const delay = getWordDelay(currentWord, getBaseDelay());
|
const chunk = words.slice(currentIndex, endIndex).join(" ");
|
||||||
|
const baseDelay = getTargetStepDelayMs() * speedScaleRef.current;
|
||||||
|
const delay = getWordDelay(chunk, baseDelay, punctuationPauses);
|
||||||
|
|
||||||
timeoutRef.current = window.setTimeout(() => {
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
setCurrentIndex((prev) => {
|
setCurrentIndex((prev) => {
|
||||||
@@ -245,7 +358,68 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||||
};
|
};
|
||||||
}, [isPlaying, currentIndex, words, getBaseDelay, wordAmount]);
|
}, [isPlaying, currentIndex, words, getTargetStepDelayMs, wordAmount, punctuationPauses]);
|
||||||
|
|
||||||
|
// Track forward progress for real WPM calculations.
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = lastIndexRef.current;
|
||||||
|
lastIndexRef.current = currentIndex;
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
const delta = currentIndex - prev;
|
||||||
|
if (delta <= 0) return;
|
||||||
|
wpmEventsRef.current.push({ t: Date.now(), words: delta });
|
||||||
|
}, [currentIndex, isPlaying]);
|
||||||
|
|
||||||
|
// Control loop (every 1s):
|
||||||
|
// - Display "Real WPM" from a longer window for readability
|
||||||
|
// - Drive calibration from a shorter window + EWMA smoothing to avoid oscillation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
pruneWpmEvents(now, 60_000);
|
||||||
|
|
||||||
|
const displayStats = computeWindowStats(now, 60_000);
|
||||||
|
const controlStats = computeWindowStats(now, 15_000);
|
||||||
|
|
||||||
|
if (showRealWpm) setRealWpm(displayStats?.wpm ?? null);
|
||||||
|
|
||||||
|
const prevTick = lastControlTickRef.current ?? now;
|
||||||
|
const dt = Math.max(1, now - prevTick);
|
||||||
|
lastControlTickRef.current = now;
|
||||||
|
|
||||||
|
if (controlStats && controlStats.spanMs >= 4000 && controlStats.wordsRead >= 12) {
|
||||||
|
const alpha = 1 - Math.exp(-dt / 12_000);
|
||||||
|
const prev = observedWpmEmaRef.current;
|
||||||
|
observedWpmEmaRef.current =
|
||||||
|
prev === null
|
||||||
|
? controlStats.wpm
|
||||||
|
: prev + alpha * (controlStats.wpm - prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wpmForEta =
|
||||||
|
observedWpmEmaRef.current ?? displayStats?.wpm ?? null;
|
||||||
|
observedWpmRef.current = wpmForEta;
|
||||||
|
|
||||||
|
// Closed-loop: adjust timing so observed WPM approaches the requested WPM.
|
||||||
|
// Use a smaller/faster signal (EWMA of last ~15s) with a capped adjustment per tick.
|
||||||
|
const observed = observedWpmEmaRef.current;
|
||||||
|
if (punctuationPauses && observed !== null && observed > 0) {
|
||||||
|
const ratio = observed / wpm;
|
||||||
|
const rawAdj = Math.pow(ratio, 0.12);
|
||||||
|
const adj = Math.min(1.015, Math.max(0.985, rawAdj));
|
||||||
|
speedScaleRef.current = Math.min(
|
||||||
|
4,
|
||||||
|
Math.max(0.25, speedScaleRef.current * adj),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [computeWindowStats, isPlaying, pruneWpmEvents, punctuationPauses, showRealWpm, wpm]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -389,10 +563,13 @@ export default function App() {
|
|||||||
const orpChar = currentText[orpIndex] || "";
|
const orpChar = currentText[orpIndex] || "";
|
||||||
const afterORP = currentText.slice(orpIndex + 1);
|
const afterORP = currentText.slice(orpIndex + 1);
|
||||||
|
|
||||||
const bookStatsText =
|
const bookStatsText = (() => {
|
||||||
bookMetadata && words.length > 0
|
if (!bookMetadata || words.length === 0) return null;
|
||||||
? `${formatReadingTime((words.length - currentIndex) / wpm)} left`
|
const wordsLeft = Math.max(0, words.length - currentIndex);
|
||||||
: null;
|
const effectiveWpm = observedWpmRef.current ?? wpm;
|
||||||
|
const minutesLeft = wordsLeft / Math.max(1, effectiveWpm);
|
||||||
|
return `${formatReadingTime(minutesLeft)} left`;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
@@ -408,6 +585,8 @@ export default function App() {
|
|||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
wpm={wpm}
|
wpm={wpm}
|
||||||
onAdjustWpm={adjustWpm}
|
onAdjustWpm={adjustWpm}
|
||||||
|
showRealWpm={showRealWpm}
|
||||||
|
realWpm={realWpm}
|
||||||
onToggleSettings={() => setShowSettings((prev) => !prev)}
|
onToggleSettings={() => setShowSettings((prev) => !prev)}
|
||||||
onToggleShortcuts={() => setShowShortcuts((prev) => !prev)}
|
onToggleShortcuts={() => setShowShortcuts((prev) => !prev)}
|
||||||
onToggleInfo={() => setShowInfo((prev) => !prev)}
|
onToggleInfo={() => setShowInfo((prev) => !prev)}
|
||||||
@@ -457,11 +636,17 @@ export default function App() {
|
|||||||
wordAmount={wordAmount}
|
wordAmount={wordAmount}
|
||||||
sideOpacity={sideOpacity}
|
sideOpacity={sideOpacity}
|
||||||
fetchMetadataOnline={fetchMetadataOnline}
|
fetchMetadataOnline={fetchMetadataOnline}
|
||||||
|
showRealWpm={showRealWpm}
|
||||||
|
punctuationPauses={punctuationPauses}
|
||||||
onChangeWordAmount={setWordAmount}
|
onChangeWordAmount={setWordAmount}
|
||||||
onChangeSideOpacity={setSideOpacity}
|
onChangeSideOpacity={setSideOpacity}
|
||||||
onToggleFetchMetadataOnline={() =>
|
onToggleFetchMetadataOnline={() =>
|
||||||
setFetchMetadataOnline((prev) => !prev)
|
setFetchMetadataOnline((prev) => !prev)
|
||||||
}
|
}
|
||||||
|
onToggleShowRealWpm={() => setShowRealWpm((prev) => !prev)}
|
||||||
|
onTogglePunctuationPauses={() =>
|
||||||
|
setPunctuationPauses((prev) => !prev)
|
||||||
|
}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+31
-20
@@ -17,6 +17,8 @@ type Props = {
|
|||||||
|
|
||||||
wpm: number;
|
wpm: number;
|
||||||
onAdjustWpm: (delta: number) => void;
|
onAdjustWpm: (delta: number) => void;
|
||||||
|
showRealWpm: boolean;
|
||||||
|
realWpm: number | null;
|
||||||
|
|
||||||
onToggleSettings: () => void;
|
onToggleSettings: () => void;
|
||||||
onToggleShortcuts: () => void;
|
onToggleShortcuts: () => void;
|
||||||
@@ -35,6 +37,8 @@ export function TopBar({
|
|||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
wpm,
|
wpm,
|
||||||
onAdjustWpm,
|
onAdjustWpm,
|
||||||
|
showRealWpm,
|
||||||
|
realWpm,
|
||||||
onToggleSettings,
|
onToggleSettings,
|
||||||
onToggleShortcuts,
|
onToggleShortcuts,
|
||||||
onToggleInfo,
|
onToggleInfo,
|
||||||
@@ -89,27 +93,34 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.topCenter}>
|
<div style={styles.topCenter}>
|
||||||
<div style={styles.wpmControl} className="wpm-control">
|
<div style={styles.wpmStack}>
|
||||||
<button
|
<div style={styles.wpmControl} className="wpm-control">
|
||||||
onClick={() => onAdjustWpm(-25)}
|
<button
|
||||||
style={styles.wpmBtn}
|
onClick={() => onAdjustWpm(-25)}
|
||||||
className="wpm-btn"
|
style={styles.wpmBtn}
|
||||||
>
|
className="wpm-btn"
|
||||||
<Minus size={16} />
|
>
|
||||||
</button>
|
<Minus size={16} />
|
||||||
<div style={styles.wpmDisplay}>
|
</button>
|
||||||
<span style={styles.wpmValue} className="wpm-value">
|
<div style={styles.wpmDisplay}>
|
||||||
{wpm}
|
<span style={styles.wpmValue} className="wpm-value">
|
||||||
</span>
|
{wpm}
|
||||||
<span style={styles.wpmLabel}>WPM</span>
|
</span>
|
||||||
|
<span style={styles.wpmLabel}>WPM</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAdjustWpm(25)}
|
||||||
|
style={styles.wpmBtn}
|
||||||
|
className="wpm-btn"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{showRealWpm && (
|
||||||
onClick={() => onAdjustWpm(25)}
|
<div style={styles.realWpmText} aria-label="Real words per minute">
|
||||||
style={styles.wpmBtn}
|
Real: {realWpm === null ? "--" : realWpm} WPM
|
||||||
className="wpm-btn"
|
</div>
|
||||||
>
|
)}
|
||||||
<Plus size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ type Props = {
|
|||||||
wordAmount: number;
|
wordAmount: number;
|
||||||
sideOpacity: number;
|
sideOpacity: number;
|
||||||
fetchMetadataOnline: boolean;
|
fetchMetadataOnline: boolean;
|
||||||
|
showRealWpm: boolean;
|
||||||
|
punctuationPauses: boolean;
|
||||||
onChangeWordAmount: (next: number) => void;
|
onChangeWordAmount: (next: number) => void;
|
||||||
onChangeSideOpacity: (next: number) => void;
|
onChangeSideOpacity: (next: number) => void;
|
||||||
onToggleFetchMetadataOnline: () => void;
|
onToggleFetchMetadataOnline: () => void;
|
||||||
|
onToggleShowRealWpm: () => void;
|
||||||
|
onTogglePunctuationPauses: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,9 +19,13 @@ export function SettingsModal({
|
|||||||
wordAmount,
|
wordAmount,
|
||||||
sideOpacity,
|
sideOpacity,
|
||||||
fetchMetadataOnline,
|
fetchMetadataOnline,
|
||||||
|
showRealWpm,
|
||||||
|
punctuationPauses,
|
||||||
onChangeWordAmount,
|
onChangeWordAmount,
|
||||||
onChangeSideOpacity,
|
onChangeSideOpacity,
|
||||||
onToggleFetchMetadataOnline,
|
onToggleFetchMetadataOnline,
|
||||||
|
onToggleShowRealWpm,
|
||||||
|
onTogglePunctuationPauses,
|
||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
@@ -102,9 +110,58 @@ export function SettingsModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={styles.settingRow}>
|
||||||
|
<label style={styles.settingLabel}>
|
||||||
|
<span>Show real WPM</span>
|
||||||
|
<span style={styles.settingHint}>Updates every 1s from last 60s</span>
|
||||||
|
</label>
|
||||||
|
<div style={styles.settingControl}>
|
||||||
|
<button
|
||||||
|
onClick={onToggleShowRealWpm}
|
||||||
|
style={{
|
||||||
|
...styles.toggleBtn,
|
||||||
|
backgroundColor: showRealWpm ? "#ff6b6b" : "#333",
|
||||||
|
}}
|
||||||
|
aria-pressed={showRealWpm}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.toggleKnob,
|
||||||
|
transform: showRealWpm
|
||||||
|
? "translateX(16px)"
|
||||||
|
: "translateX(0)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={styles.settingRow}>
|
||||||
|
<label style={styles.settingLabel}>
|
||||||
|
<span>Adaptive timing</span>
|
||||||
|
<span style={styles.settingHint}>Slows for long words and punctuation</span>
|
||||||
|
</label>
|
||||||
|
<div style={styles.settingControl}>
|
||||||
|
<button
|
||||||
|
onClick={onTogglePunctuationPauses}
|
||||||
|
style={{
|
||||||
|
...styles.toggleBtn,
|
||||||
|
backgroundColor: punctuationPauses ? "#ff6b6b" : "#333",
|
||||||
|
}}
|
||||||
|
aria-pressed={punctuationPauses}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.toggleKnob,
|
||||||
|
transform: punctuationPauses
|
||||||
|
? "translateX(16px)"
|
||||||
|
: "translateX(0)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-8
@@ -9,15 +9,39 @@ export function getORPIndex(wordLength: number): number {
|
|||||||
return 4; // 14+ chars: 5th letter
|
return 4; // 14+ chars: 5th letter
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWordDelay(word: string, baseDelayMs: number): number {
|
function stripTrailingClosers(s: string): string {
|
||||||
let multiplier = 1;
|
// Handle punctuation followed by quotes/brackets like `word."` or `word.)`.
|
||||||
multiplier += Math.sqrt(word.length) * 0.04;
|
return s.replace(/[)"'\]]+$/g, "");
|
||||||
if (/[.!?]$/.test(word)) {
|
}
|
||||||
multiplier = 2.5;
|
|
||||||
} else if (/[,;:]$/.test(word)) {
|
export function getTimingMultiplier(text: string, adaptiveTiming: boolean): number {
|
||||||
multiplier = 1.8;
|
if (!adaptiveTiming) return 1;
|
||||||
|
|
||||||
|
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (tokens.length === 0) return 1;
|
||||||
|
|
||||||
|
// Length-based slowdown: use the longest token in the display chunk.
|
||||||
|
let maxLen = 0;
|
||||||
|
for (const t of tokens) maxLen = Math.max(maxLen, t.length);
|
||||||
|
let multiplier = 1 + Math.sqrt(maxLen) * 0.04;
|
||||||
|
|
||||||
|
// Punctuation-based pauses: based on the last token.
|
||||||
|
const last = stripTrailingClosers(tokens[tokens.length - 1]);
|
||||||
|
if (/[.!?]$/.test(last)) {
|
||||||
|
multiplier = Math.max(multiplier, 2.5);
|
||||||
|
} else if (/[,;:]$/.test(last)) {
|
||||||
|
multiplier = Math.max(multiplier, 1.8);
|
||||||
}
|
}
|
||||||
return baseDelayMs * multiplier;
|
|
||||||
|
return multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWordDelay(
|
||||||
|
text: string,
|
||||||
|
baseDelayMs: number,
|
||||||
|
adaptiveTiming: boolean,
|
||||||
|
): number {
|
||||||
|
return baseDelayMs * getTimingMultiplier(text, adaptiveTiming);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatReadingTime(minutes: number): string {
|
export function formatReadingTime(minutes: number): string {
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export const styles: Record<string, CSSProperties> =
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: "#1a1a1a",
|
||||||
},
|
},
|
||||||
|
wpmStack: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
},
|
||||||
wpmControl: {
|
wpmControl: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -99,6 +105,11 @@ export const styles: Record<string, CSSProperties> =
|
|||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.08em",
|
letterSpacing: "0.08em",
|
||||||
},
|
},
|
||||||
|
realWpmText: {
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
color: "#666",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
},
|
||||||
|
|
||||||
// Text input panel
|
// Text input panel
|
||||||
textInputOverlay: {
|
textInputOverlay: {
|
||||||
|
|||||||
+2
-1
@@ -14,10 +14,11 @@ export type ReaderSettings = {
|
|||||||
wordAmount: number;
|
wordAmount: number;
|
||||||
bookMetadata: BookMetadata | null;
|
bookMetadata: BookMetadata | null;
|
||||||
fetchMetadataOnline: boolean;
|
fetchMetadataOnline: boolean;
|
||||||
|
showRealWpm: boolean;
|
||||||
|
punctuationPauses: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EpubParseResult = {
|
export type EpubParseResult = {
|
||||||
text: string;
|
text: string;
|
||||||
metadata: BookMetadata;
|
metadata: BookMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user