import { Settings as SettingsIcon } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { FocusDisplay } from './components/FocusDisplay'; import { SettingsDrawer } from './components/SettingsDrawer'; import { calculateWpm } from './core/timing'; import { tokenizeEnglishText } from './core/tokenizer'; import { loadSavedState, saveState } from './db'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useReaderEngine } from './hooks/useReaderEngine'; import type { FontSize, FocusState } from './types'; export default function App() { const [saved] = useState(() => loadSavedState()); const [flashesPerMinute, setFlashesPerMinute] = useState( saved.flashesPerMinute ); const [wordsPerChunk, setWordsPerChunk] = useState(saved.wordsPerChunk); const [fontSize, setFontSize] = useState(saved.fontSize); const [words, setWords] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isUserActive, setIsUserActive] = useState(true); const inactivityTimerRef = useRef(null); // Load built-in book text useEffect(() => { async function loadBook() { try { const res = await fetch( encodeURI('/books/The Well of Ascension.txt') ); if (!res.ok) { throw new Error(`Failed to load book: ${res.statusText}`); } const text = await res.text(); const parsedWords = tokenizeEnglishText(text); setWords(parsedWords); } catch (err) { console.error('Failed to load book:', err); } finally { setIsLoading(false); } } loadBook(); }, []); // Sync state changes to localStorage const handleFpmChange = (newFpm: number) => { const clamped = Math.min(600, Math.max(60, newFpm)); setFlashesPerMinute(clamped); saveState({ flashesPerMinute: clamped }); }; const handleWordsPerChunkChange = (newChunk: number) => { const clamped = Math.min(4, Math.max(1, newChunk)); setWordsPerChunk(clamped); saveState({ wordsPerChunk: clamped }); }; const handleFontSizeChange = (newSize: FontSize) => { setFontSize(newSize); saveState({ fontSize: newSize }); }; // Throttled persistence of wordIndex to localStorage const lastSavedIndexRef = useRef(saved.wordIndex); const saveTimeoutRef = useRef(null); const handleWordIndexUpdate = useCallback((index: number) => { if (saveTimeoutRef.current) { window.clearTimeout(saveTimeoutRef.current); } saveTimeoutRef.current = window.setTimeout(() => { if (index !== lastSavedIndexRef.current) { lastSavedIndexRef.current = index; saveState({ wordIndex: index }); } }, 400); }, []); // Save immediately on unload useEffect(() => { const handleBeforeUnload = () => { saveState({ wordIndex: lastSavedIndexRef.current, flashesPerMinute, wordsPerChunk, fontSize, }); }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [flashesPerMinute, wordsPerChunk, fontSize]); // Core reading engine const { currentIndex, currentChunkText, isPlaying, togglePlay, pause, jumpTo, jumpBy, restart, totalWords, } = useReaderEngine({ words, flashesPerMinute, wordsPerChunk, initialWordIndex: saved.wordIndex, onWordIndexChange: (idx) => { lastSavedIndexRef.current = idx; handleWordIndexUpdate(idx); }, }); // Reset inactivity timer const resetInactivity = useCallback(() => { setIsUserActive(true); if (inactivityTimerRef.current) { window.clearTimeout(inactivityTimerRef.current); } if (isPlaying) { inactivityTimerRef.current = window.setTimeout(() => { setIsUserActive(false); }, 2500); } }, [isPlaying]); useEffect(() => { if (!isPlaying) { if (inactivityTimerRef.current) { window.clearTimeout(inactivityTimerRef.current); inactivityTimerRef.current = null; } return; } const onActivity = () => resetInactivity(); window.addEventListener('mousemove', onActivity); window.addEventListener('keydown', onActivity); window.addEventListener('touchstart', onActivity); inactivityTimerRef.current = window.setTimeout(() => { setIsUserActive(false); }, 2500); return () => { window.removeEventListener('mousemove', onActivity); window.removeEventListener('keydown', onActivity); window.removeEventListener('touchstart', onActivity); if (inactivityTimerRef.current) { window.clearTimeout(inactivityTimerRef.current); } }; }, [isPlaying, resetInactivity]); // Fullscreen toggle const toggleFullscreen = () => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(() => {}); } else { document.exitFullscreen().catch(() => {}); } }; // Keyboard navigation useKeyboardShortcuts({ togglePlay, jumpBy, changeFpm: (delta) => handleFpmChange(flashesPerMinute + delta), toggleFullscreen, onEscape: () => { if (isSettingsOpen) { setIsSettingsOpen(false); } else { pause(); } }, isDrawerOpen: isSettingsOpen, }); const progressPercent = totalWords > 0 ? Math.min(100, Math.max(0, (currentIndex / (totalWords - 1 || 1)) * 100)) : 0; const wpm = calculateWpm(flashesPerMinute, wordsPerChunk); const hudOpacity = isPlaying && !isUserActive ? 'opacity-0 pointer-events-none' : 'opacity-100 pointer-events-auto'; return (
{/* Top Subtle Bar */}
The Well of Ascension {/* Quiet Gear Button */}
{/* Main RSVPs Central Stream */}
{isLoading ? (
Loading Focus...
) : ( { resetInactivity(); togglePlay(); }} /> )}
{/* Bottom Ultra-Thin Progress & Speed HUD */}
{/* Ultra-thin progress line */}
{ const rect = e.currentTarget.getBoundingClientRect(); const clickPos = (e.clientX - rect.left) / rect.width; const target = Math.round(clickPos * (totalWords - 1)); jumpTo(target); }} className="relative w-full h-1 bg-zinc-800/80 hover:h-2 rounded-full cursor-pointer transition-all group overflow-hidden" title="点击调整进度" >
{/* Status text */}
{(currentIndex + 1).toLocaleString()} / {totalWords.toLocaleString()}{' '} ({progressPercent.toFixed(1)}%) {wpm} WPM{' '} ({flashesPerMinute} FPM × {wordsPerChunk})
{/* Slide-out Settings Drawer */} setIsSettingsOpen(false)} flashesPerMinute={flashesPerMinute} onChangeFpm={handleFpmChange} wordsPerChunk={wordsPerChunk} onChangeWordsPerChunk={handleWordsPerChunkChange} fontSize={fontSize} onChangeFontSize={handleFontSizeChange} wordIndex={currentIndex} totalWords={totalWords} onJumpTo={jumpTo} onResetProgress={() => { restart(); saveState({ wordIndex: 0 }); }} />
); }