-
-
Notifications
You must be signed in to change notification settings - Fork 519
Feature/rsvp speed reading #2537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Feature/rsvp speed reading #2537
Conversation
Introduces a new speed reading mode that displays words one at a time with configurable WPM. Features include: - ORP (Optimal Recognition Point) highlighting for faster reading - Adjustable speed (100-1000 WPM) - Play/pause, skip forward/backward controls - Keyboard and touch gesture support - Position saving and resume capability - Start from selection, visible text, or chapter beginning
- nextAsync(): Navigate to next page and wait for relocate event - getCurrentCfi(): Get current CFI directly from view for accurate position
- Add speed reading button to header with new speed icon - Wire up RSVP service and overlay component - Add start choice dialog for selecting start position - Handle page navigation and position highlighting on stop
Position the ORP character at a fixed center point so eyes don't need to track horizontal movement between words.
- Change skip forward/back to 15 words with visible labels - Add chapter dropdown selector for navigation within RSVP mode - Add 3-2-1 countdown timer on start, resume, and new chapter - Improve progress bar with "Chapter Progress" label, word count, and estimated time remaining - Center playback controls with balanced layout
- Use theme link color for accent (progress bar, ORP, countdown) - Replace horizontal guide lines with vertical line through focal point - Remove underline from ORP character for cleaner look
- Add context panel showing ~100 words around current word - Context panel appears automatically when paused - Current word highlighted in accent color - Remove hint text for cleaner UI - Improve mobile responsiveness for controls and context
- Add rsvp.service.spec.ts with tests for ORP calculation, pause multiplier, WPM controls, skip navigation, CFI comparison, and selection matching - Add rsvp-overlay.component.spec.ts with tests for word display, time remaining, context text, chapter flattening, and control actions - Fix edge case in findWordIndexBySelection for whitespace-only selections
|
Marking this as a draft for now, reading session is not being properly tracked. Looking into it |
- Reduce default punctuation pause from 1000ms to 500ms - Add dropdown control to adjust pause duration (100ms-1000ms) - Save pause setting per-book in localStorage - Update tests for new punctuationPauseMs state property
- Fix bug where RSVP would restart current chapter instead of advancing to next chapter when reaching end of section - Add goToNextSection() to ViewManager for explicit section navigation - Change punctuation pause options from 100-1000ms to 25-200ms in 25ms increments for finer control - Update tests to match new default punctuation pause value (100ms)
|
Fixed reading session tracking. |
|
@boludo00 Thanks for the PR, this is seriously impressive work. The feature looks very polished and thoughtfully implemented. I haven’t had a chance to try it hands-on yet, but I’ll give it a proper go soon and share honest feedback once I do. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Adds an RSVP (Rapid Serial Visual Presentation) “speed reading” mode to the ebook reader, including a full-screen overlay UI, playback/state management, and navigation support.
Changes:
- Introduces
RsvpServicefor word extraction, playback timing, position persistence, and start/resume flows. - Adds
RsvpOverlayComponentUI (word display with ORP, controls, chapter dropdown, progress, touch/keyboard interactions). - Extends reader integration: header “Speed Read (RSVP)” action, view-manager helpers (
getCurrentCfi,nextAsync,goToNextSection), and start-choice dialog UI.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| booklore-ui/src/app/features/readers/ebook-reader/shared/icon.component.ts | Adds a new speed icon for RSVP entry point. |
| booklore-ui/src/app/features/readers/ebook-reader/layout/header/header.service.ts | Adds startRsvp$ event stream to trigger RSVP from the header. |
| booklore-ui/src/app/features/readers/ebook-reader/layout/header/header.component.ts | Wires header click handler to start RSVP. |
| booklore-ui/src/app/features/readers/ebook-reader/layout/header/header.component.html | Adds the “Speed Read (RSVP)” button to the header UI. |
| booklore-ui/src/app/features/readers/ebook-reader/features/rsvp/rsvp.service.ts | Implements RSVP extraction/playback/state, persistence, and reader-session updates. |
| booklore-ui/src/app/features/readers/ebook-reader/features/rsvp/rsvp.service.spec.ts | Unit tests for RSVP logic (ORP, pacing, bounds, CFI matching, selection matching). |
| booklore-ui/src/app/features/readers/ebook-reader/features/rsvp/rsvp-overlay.component.ts | Implements the RSVP overlay component logic and interactions. |
| booklore-ui/src/app/features/readers/ebook-reader/features/rsvp/rsvp-overlay.component.html | RSVP overlay template (chapter selector, word display, controls, progress). |
| booklore-ui/src/app/features/readers/ebook-reader/features/rsvp/rsvp-overlay.component.scss | RSVP overlay styling (layout, controls, progress bar, responsive rules). |
| booklore-ui/src/app/features/readers/ebook-reader/features/rsvp/rsvp-overlay.component.spec.ts | Unit tests for overlay behavior and computed outputs. |
| booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.ts | Integrates RSVP start flow, overlay, next-page handling, and stop highlighting. |
| booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.html | Renders RSVP overlay and start-choice dialog UI. |
| booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.scss | Styles the RSVP start-choice dialog overlay. |
| booklore-ui/src/app/features/readers/ebook-reader/core/view-manager.service.ts | Exports TocItem, adds CFI/section helpers and async-next/next-section navigation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| playing: false, // Start paused, countdown will set to playing | ||
| words, | ||
| currentIndex: startIndex, | ||
| progress: (startIndex / words.length) * 100, | ||
| resumedFromIndex |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Progress is computed as (startIndex / words.length) * 100, which makes the progress bar show 0% while the UI shows currentIndex + 1 (first word), and it will never reach 100% (last word is (len-1)/len). Consider using (currentIndex + 1) / words.length * 100 (guarding for words.length === 0) and reusing the same helper everywhere progress is updated.
| toggleContext(): void { | ||
| this.showContext = !this.showContext; | ||
| } |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
showContext / toggleContext() appear unused (the template always shows the context panel when paused, and no UI element toggles it). Either wire this up (e.g., add a context toggle button and conditionally render the panel) or remove the unused state/method.
| return new Observable(subscriber => { | ||
| const handler = () => { | ||
| this.view?.removeEventListener('relocate', handler); | ||
| subscriber.next(); | ||
| subscriber.complete(); | ||
| }; |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nextAsync() creates a custom Observable but doesn’t return a teardown to remove the relocate listener if the subscriber unsubscribes early. This can leak listeners and cause unexpected callbacks. Return a cleanup function that removes the listener and clears any pending timeout.
| 'auto-fit': [ | ||
| {d: 'M12 3v3m0 12v3M3 12h3m12 0h3'}, | ||
| {d: 'M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0-8 0', type: 'path'} | ||
| ], | ||
| 'speed': [ |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ICONS object literal is missing a comma after the 'auto-fit' entry. As written, 'speed' becomes an invalid token and this file won’t compile. Add a trailing comma after the 'auto-fit' array.
| this.updateState({ | ||
| currentIndex: newIndex, | ||
| progress: (newIndex / state.words.length) * 100 |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Progress calculation here uses (newIndex / state.words.length) * 100, which makes the progress bar lag the visible currentIndex + 1 word count and never reach 100% on the last word. Consider (newIndex + 1) / state.words.length * 100 with a length === 0 guard.
| this.updateState({ | |
| currentIndex: newIndex, | |
| progress: (newIndex / state.words.length) * 100 | |
| const totalWords = state.words.length; | |
| const progress = totalWords === 0 ? 0 : ((newIndex + 1) / totalWords) * 100; | |
| this.updateState({ | |
| currentIndex: newIndex, | |
| progress |
| setTimeout(() => { | ||
| this.view?.removeEventListener('relocate', handler); | ||
| subscriber.next(); | ||
| subscriber.complete(); | ||
| }, 1000); |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The setTimeout fallback in nextAsync() isn’t cleared when relocate fires, so it will still run later and do extra work (and can run after unsubscription). Store the timeout id and clear it in the relocate handler / teardown.
| this.updateState({ | ||
| currentIndex: newIndex, | ||
| progress: (newIndex / state.words.length) * 100 | ||
| }); | ||
| } | ||
|
|
||
| skipBackward(count: number = 10): void { | ||
| const state = this.currentState; | ||
| const newIndex = Math.max(0, state.currentIndex - count); | ||
| this.updateState({ | ||
| currentIndex: newIndex, | ||
| progress: (newIndex / state.words.length) * 100 |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Progress calculation here uses (newIndex / state.words.length) * 100, which mismatches the displayed word count (currentIndex + 1) and prevents the bar from ever reaching 100%. Align the formula with the UI (e.g., (newIndex + 1) / state.words.length * 100, with a length === 0 guard) and keep it consistent across all updates.
| this.updateState({ | |
| currentIndex: newIndex, | |
| progress: (newIndex / state.words.length) * 100 | |
| }); | |
| } | |
| skipBackward(count: number = 10): void { | |
| const state = this.currentState; | |
| const newIndex = Math.max(0, state.currentIndex - count); | |
| this.updateState({ | |
| currentIndex: newIndex, | |
| progress: (newIndex / state.words.length) * 100 | |
| const totalWords = state.words.length; | |
| const progress = totalWords === 0 ? 0 : ((newIndex + 1) / totalWords) * 100; | |
| this.updateState({ | |
| currentIndex: newIndex, | |
| progress | |
| }); | |
| } | |
| skipBackward(count: number = 10): void { | |
| const state = this.currentState; | |
| const newIndex = Math.max(0, state.currentIndex - count); | |
| const totalWords = state.words.length; | |
| const progress = totalWords === 0 ? 0 : ((newIndex + 1) / totalWords) * 100; | |
| this.updateState({ | |
| currentIndex: newIndex, | |
| progress |
| if (words.length === 0) { | ||
| // Retry up to 3 times with increasing delay if no words found | ||
| if (retryCount < 3) { | ||
| setTimeout(() => this.start(retryCount + 1), 150 * (retryCount + 1)); |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
start() schedules retries via setTimeout but doesn’t retain/cancel the timer. If the user closes RSVP (or the component is destroyed) while a retry is pending, the callback can still run later and start playback in the background. Store the retry timeout id and clear it in stop()/reset() (and/or check this.currentState.active/a "canceled" flag inside the retry callback).
| setTimeout(() => this.start(retryCount + 1), 150 * (retryCount + 1)); | |
| setTimeout(() => { | |
| // Only retry if RSVP is still active to avoid restarting after stop/reset | |
| if (this.currentState && this.currentState.active) { | |
| this.start(retryCount + 1); | |
| } | |
| }, 150 * (retryCount + 1)); |
| private tokenizeText(text: string): RsvpWord[] { | ||
| const words = text.split(/\s+/).filter(w => w.length > 0); | ||
|
|
||
| return words.map(word => ({ | ||
| text: word, | ||
| orpIndex: this.calculateORP(word), | ||
| pauseMultiplier: this.getPauseMultiplier(word) | ||
| })); | ||
| } | ||
|
|
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tokenizeText() is currently unused in this service. Consider removing it (or using it) to keep the RSVP implementation focused and avoid dead code.
| private tokenizeText(text: string): RsvpWord[] { | |
| const words = text.split(/\s+/).filter(w => w.length > 0); | |
| return words.map(word => ({ | |
| text: word, | |
| orpIndex: this.calculateORP(word), | |
| pauseMultiplier: this.getPauseMultiplier(word) | |
| })); | |
| } |
| @@ -0,0 +1,307 @@ | |||
| import {ComponentFixture, TestBed} from '@angular/core/testing'; | |||
| import {vi, describe, it, expect, beforeEach} from 'vitest'; | |||
| import {RsvpOverlayComponent, FlatChapter} from './rsvp-overlay.component'; | |||
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused import FlatChapter.
| import {RsvpOverlayComponent, FlatChapter} from './rsvp-overlay.component'; | |
| import {RsvpOverlayComponent} from './rsvp-overlay.component'; |
@acx10 Great! That would be amazing. If you want, you can just pull this image from that fork if it's easier: |
📝 Description
Adds RSVP (Rapid Serial Visual Presentation) speed reading mode to the ebook reader. This feature displays words one at a time at a configurable speed, with the focal point highlighted and centered for
faster reading. Implements #2209
🛠️ Changes Implemented
RsvpServicefor word extraction, playback control, and state managementRsvpOverlayComponentas the full-screen RSVP reader UIRsvpStartDialogComponentfor start position selectionnextAsync,getCurrentCfi) to support RSVPTocIteminterface for chapter navigation🧪 Testing Strategy
rsvp.service.spec.tswith 38 unit tests covering:rsvp-overlay.component.spec.tswith 18 unit tests covering:📸 Visual Changes (if applicable)
This PR introduces a new full-screen RSVP overlay for speed reading. The UI includes:
rsvp_booklore_demo2.mov
Please Read - This Checklist is Mandatory
Mandatory Requirements (please check ALL boxes):
developbranch💬 Additional Context (optional)
RSVP is a speed reading technique that presents words sequentially at a fixed point, reducing eye movement and potentially increasing reading speed. The ORP (Optimal Recognition Point) is the character in each word where the eye naturally
focuses - highlighting this character helps with word recognition at high speeds."