feat(rsvp): progress bar per chapter, speed selector dropdown, and UX improvements (#3723)

* fix(rsvp): rename pause setting to punctuation delay for clarity

* fix(rsvp): update chapter header as user crosses TOC sections

* fix(rsvp): stop playback at end of book instead of restarting last chapter

* feat(rsvp): convert WPM badge to speed selector dropdown

* feat(rsvp): scroll active chapter to center when chapter dropdown opens

* fix(rsvp): show correct chapter name in header instead of "Select Chapter"

* fix(rsvp): start from selected chapter when switching via dropdown

* fix(rsvp): restore saved position when resuming from a different section

* fix(rsvp): remove shrink animation from header buttons on click

* refactor(rsvp): process one spine section at a time, greadtly reducing complexity

* refactor(rsvp): remove dead state and derive progress from currentIndex

* feat(rsvp): allow pausing during countdown

* fix(rsvp): use relocate event instead of fixed 500ms delay for chapter transitions

* fix(rsvp): persist WPM and punctuation pause settings across sessions

* fix(rsvp): prefix unused bookKey field with underscore to satisfy lint

* Revert "fix(rsvp): prefix unused bookKey field with underscore to satisfy lint"

This reverts commit 7b3a34813f0c3771e9af8d00c7c7dc77c8db161c.

* fix(rsvp): remove unused bookKey field from RSVPController

* feat(rsvp): make progress bar draggable

* fix: revert settings.json changes

* fix(rsvp): restore genuinely useful comments

* i18n: update translations

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
Lex Moulton
2026-04-01 18:31:58 -07:00
committed by GitHub
parent 9ecb9b24d2
commit c9647276b1
35 changed files with 443 additions and 256 deletions
@@ -1030,7 +1030,6 @@
"words": "كلمات",
"{{time}} left": "متبقي {{time}}",
"Reading progress": "تقدم القراءة",
"Click to seek": "انقر للتمرير",
"Skip back 15 words": "تخطي للخلف 15 كلمة",
"Back 15 words (Shift+Left)": "للخلف 15 كلمة (Shift+اليسار)",
"Pause (Space)": "إيقاف مؤقت (المسافة)",
@@ -1203,5 +1202,8 @@
"Every Sentence": "كل جملة",
"Every Paragraph": "كل فقرة",
"Every Chapter": "كل فصل",
"TTS Media Info Update Frequency": "تردد تحديث معلومات وسائط TTS"
"TTS Media Info Update Frequency": "تردد تحديث معلومات وسائط TTS",
"Select reading speed": "اختر سرعة القراءة",
"Drag to seek": "اسحب للتقديم",
"Punctuation Delay": "تأخير علامات الترقيم"
}
@@ -982,7 +982,6 @@
"words": "শব্দ",
"{{time}} left": "{{time}} বাকি",
"Reading progress": "পড়ার অগ্রগতি",
"Click to seek": "খুঁজতে ক্লিক করুন",
"Skip back 15 words": "১৫ শব্দ পিছিয়ে যান",
"Back 15 words (Shift+Left)": "১৫ শব্দ পিছিয়ে যান (Shift+Left)",
"Pause (Space)": "বিরতি (Space)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "প্রতিটি বাক্য",
"Every Paragraph": "প্রতিটি অনুচ্ছেদ",
"Every Chapter": "প্রতিটি অধ্যায়",
"TTS Media Info Update Frequency": "TTS মিডিয়া তথ্য আপডেট ফ্রিকোয়েন্সি"
"TTS Media Info Update Frequency": "TTS মিডিয়া তথ্য আপডেট ফ্রিকোয়েন্সি",
"Select reading speed": "পড়ার গতি নির্বাচন করুন",
"Drag to seek": "খুঁজতে টানুন",
"Punctuation Delay": "বিরাম চিহ্ন বিলম্ব"
}
@@ -970,7 +970,6 @@
"words": "ཚིག",
"{{time}} left": "དུས་ཚོད་ {{time}} ལྷག་ཡོད།",
"Reading progress": "ཀློག་པའི་འཕེལ་རིམ།",
"Click to seek": "གནས་ས་འཚོལ་བར་གནོན་པ།",
"Skip back 15 words": "ཚིག་ ༡༥ རྒྱབ་ལ་བཤུད་པ།",
"Back 15 words (Shift+Left)": "ཚིག་ ༡༥ རྒྱབ་ལ་བཤུད་པ། (Shift+Left)",
"Pause (Space)": "མཚམས་འཇོག་པ། (Space)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "ཚིག་གྲུབ་རེ་རེ",
"Every Paragraph": "དུམ་མཚམས་རེ་རེ",
"Every Chapter": "ལེའུ་རེ་རེ",
"TTS Media Info Update Frequency": "TTS སྨྱན་བྱད་ཆ་འཕྲིན་གསར་སྒྱུར་ཐེངས་གྲངས"
"TTS Media Info Update Frequency": "TTS སྨྱན་བྱད་ཆ་འཕྲིན་གསར་སྒྱུར་ཐེངས་གྲངས",
"Select reading speed": "ཀློག་པའི་མྱུར་ཚད་འདེམས།",
"Drag to seek": "འཚོལ་བར་འདྲུད།",
"Punctuation Delay": "ཚེག་ཤད་ཕྱིར་འགྱངས།"
}
@@ -982,7 +982,6 @@
"words": "Wörter",
"{{time}} left": "{{time}} übrig",
"Reading progress": "Lesefortschritt",
"Click to seek": "Klicken zum Suchen",
"Skip back 15 words": "15 Wörter zurück",
"Back 15 words (Shift+Left)": "15 Wörter zurück (Shift+Links)",
"Pause (Space)": "Pause (Leertaste)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "Jeden Satz",
"Every Paragraph": "Jeden Absatz",
"Every Chapter": "Jedes Kapitel",
"TTS Media Info Update Frequency": "TTS-Medieninfo-Aktualisierungshäufigkeit"
"TTS Media Info Update Frequency": "TTS-Medieninfo-Aktualisierungshäufigkeit",
"Select reading speed": "Lesegeschwindigkeit wählen",
"Drag to seek": "Ziehen zum Suchen",
"Punctuation Delay": "Satzzeichen-Verzögerung"
}
@@ -982,7 +982,6 @@
"words": "λέξεις",
"{{time}} left": "{{time}} απομένουν",
"Reading progress": "Πρόοδος ανάγνωσης",
"Click to seek": "Κάντε κλικ για αναζήτηση",
"Skip back 15 words": "Επιστροφή 15 λέξεων",
"Back 15 words (Shift+Left)": "Πίσω 15 λέξεις (Shift+Αριστερά)",
"Pause (Space)": "Παύση (Διάστημα)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "Κάθε πρόταση",
"Every Paragraph": "Κάθε παράγραφο",
"Every Chapter": "Κάθε κεφάλαιο",
"TTS Media Info Update Frequency": "Συχνότητα ενημέρωσης πληροφοριών μέσων TTS"
"TTS Media Info Update Frequency": "Συχνότητα ενημέρωσης πληροφοριών μέσων TTS",
"Select reading speed": "Επιλέξτε ταχύτητα ανάγνωσης",
"Drag to seek": "Σύρετε για αναζήτηση",
"Punctuation Delay": "Καθυστέρηση στίξης"
}
@@ -994,7 +994,6 @@
"words": "palabras",
"{{time}} left": "faltan {{time}}",
"Reading progress": "Progreso de lectura",
"Click to seek": "Hacer clic para buscar",
"Skip back 15 words": "Retroceder 15 palabras",
"Back 15 words (Shift+Left)": "Retroceder 15 palabras (Shift+Izquierda)",
"Pause (Space)": "Pausa (Espacio)",
@@ -1164,5 +1163,8 @@
"Every Sentence": "Cada oración",
"Every Paragraph": "Cada párrafo",
"Every Chapter": "Cada capítulo",
"TTS Media Info Update Frequency": "Frecuencia de actualización de info multimedia TTS"
"TTS Media Info Update Frequency": "Frecuencia de actualización de info multimedia TTS",
"Select reading speed": "Seleccionar velocidad de lectura",
"Drag to seek": "Arrastra para buscar",
"Punctuation Delay": "Retraso de puntuación"
}
@@ -982,7 +982,6 @@
"words": "کلمات",
"{{time}} left": "{{time}} باقی‌مانده",
"Reading progress": "پیشرفت مطالعه",
"Click to seek": "برای جستجو کلیک کنید",
"Skip back 15 words": "۱۵ کلمه به عقب",
"Back 15 words (Shift+Left)": "۱۵ کلمه به عقب (Shift+Left)",
"Pause (Space)": "توقف (Space)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "هر جمله",
"Every Paragraph": "هر پاراگراف",
"Every Chapter": "هر فصل",
"TTS Media Info Update Frequency": "تناوب به‌روزرسانی اطلاعات رسانه TTS"
"TTS Media Info Update Frequency": "تناوب به‌روزرسانی اطلاعات رسانه TTS",
"Select reading speed": "انتخاب سرعت خواندن",
"Drag to seek": "بکشید برای جستجو",
"Punctuation Delay": "تأخیر نشانه‌گذاری"
}
@@ -994,7 +994,6 @@
"words": "mots",
"{{time}} left": "{{time}} restant",
"Reading progress": "Progression de la lecture",
"Click to seek": "Cliquer pour chercher",
"Skip back 15 words": "Reculer de 15 mots",
"Back 15 words (Shift+Left)": "Reculer de 15 mots (Shift+Left)",
"Pause (Space)": "Pause (Espace)",
@@ -1164,5 +1163,8 @@
"Every Sentence": "Chaque phrase",
"Every Paragraph": "Chaque paragraphe",
"Every Chapter": "Chaque chapitre",
"TTS Media Info Update Frequency": "Fréquence de mise à jour des infos média TTS"
"TTS Media Info Update Frequency": "Fréquence de mise à jour des infos média TTS",
"Select reading speed": "Sélectionner la vitesse de lecture",
"Drag to seek": "Glisser pour chercher",
"Punctuation Delay": "Délai de ponctuation"
}
@@ -493,7 +493,6 @@
"words": "מילים",
"{{time}} left": "נותרו {{time}}",
"Reading progress": "התקדמות קריאה",
"Click to seek": "לחץ לחיפוש",
"Skip back 15 words": "קפוץ 15 מילים אחורה",
"Back 15 words (Shift+Left)": "15 מילים אחורה (Shift+Left)",
"Pause": "השהה",
@@ -1164,5 +1163,8 @@
"Every Sentence": "כל משפט",
"Every Paragraph": "כל פסקה",
"Every Chapter": "כל פרק",
"TTS Media Info Update Frequency": "תדירות עדכון פרטי מדיה TTS"
"TTS Media Info Update Frequency": "תדירות עדכון פרטי מדיה TTS",
"Select reading speed": "בחר מהירות קריאה",
"Drag to seek": "גרור לחיפוש",
"Punctuation Delay": "השהיית פיסוק"
}
@@ -982,7 +982,6 @@
"words": "शब्द",
"{{time}} left": "{{time}} शेष",
"Reading progress": "पठन प्रगति",
"Click to seek": "सीक करने के लिए क्लिक करें",
"Skip back 15 words": "15 शब्द पीछे",
"Back 15 words (Shift+Left)": "15 शब्द पीछे (Shift+Left)",
"Pause (Space)": "विराम (Space)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "हर वाक्य",
"Every Paragraph": "हर पैराग्राफ",
"Every Chapter": "हर अध्याय",
"TTS Media Info Update Frequency": "TTS मीडिया जानकारी अपडेट आवृत्ति"
"TTS Media Info Update Frequency": "TTS मीडिया जानकारी अपडेट आवृत्ति",
"Select reading speed": "पढ़ने की गति चुनें",
"Drag to seek": "खोजने के लिए खींचें",
"Punctuation Delay": "विराम चिह्न विलंब"
}
@@ -970,7 +970,6 @@
"words": "kata",
"{{time}} left": "{{time}} tersisa",
"Reading progress": "Kemajuan membaca",
"Click to seek": "Klik untuk mencari",
"Skip back 15 words": "Lompat mundur 15 kata",
"Back 15 words (Shift+Left)": "Mundur 15 kata (Shift+Kiri)",
"Pause (Space)": "Jeda (Spasi)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "Setiap Kalimat",
"Every Paragraph": "Setiap Paragraf",
"Every Chapter": "Setiap Bab",
"TTS Media Info Update Frequency": "Frekuensi Pembaruan Info Media TTS"
"TTS Media Info Update Frequency": "Frekuensi Pembaruan Info Media TTS",
"Select reading speed": "Pilih kecepatan membaca",
"Drag to seek": "Seret untuk mencari",
"Punctuation Delay": "Jeda tanda baca"
}
@@ -994,7 +994,6 @@
"words": "parole",
"{{time}} left": "{{time}} rimanenti",
"Reading progress": "Progresso lettura",
"Click to seek": "Clicca per cercare",
"Skip back 15 words": "Indietro di 15 parole",
"Back 15 words (Shift+Left)": "Indietro di 15 parole (Shift+Sinistra)",
"Pause (Space)": "Pausa (Spazio)",
@@ -1164,5 +1163,8 @@
"Every Sentence": "Ogni frase",
"Every Paragraph": "Ogni paragrafo",
"Every Chapter": "Ogni capitolo",
"TTS Media Info Update Frequency": "Frequenza aggiornamento info multimediali TTS"
"TTS Media Info Update Frequency": "Frequenza aggiornamento info multimediali TTS",
"Select reading speed": "Seleziona velocità di lettura",
"Drag to seek": "Trascina per cercare",
"Punctuation Delay": "Ritardo punteggiatura"
}
@@ -970,7 +970,6 @@
"words": "単語",
"{{time}} left": "残り {{time}}",
"Reading progress": "読書の進捗",
"Click to seek": "クリックしてシーク",
"Skip back 15 words": "15単語戻る",
"Back 15 words (Shift+Left)": "15単語戻る (Shift+左)",
"Pause (Space)": "一時停止 (Space)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "文ごと",
"Every Paragraph": "段落ごと",
"Every Chapter": "章ごと",
"TTS Media Info Update Frequency": "TTSメディア情報の更新頻度"
"TTS Media Info Update Frequency": "TTSメディア情報の更新頻度",
"Select reading speed": "読み上げ速度を選択",
"Drag to seek": "ドラッグしてシーク",
"Punctuation Delay": "句読点の遅延"
}
@@ -970,7 +970,6 @@
"words": "단어",
"{{time}} left": "{{time}} 남음",
"Reading progress": "독서 진행 상황",
"Click to seek": "클릭하여 탐색",
"Skip back 15 words": "15단어 뒤로",
"Back 15 words (Shift+Left)": "15단어 뒤로 (Shift+왼쪽 화살표)",
"Pause (Space)": "일시정지 (스페이스바)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "문장마다",
"Every Paragraph": "단락마다",
"Every Chapter": "챕터마다",
"TTS Media Info Update Frequency": "TTS 미디어 정보 업데이트 빈도"
"TTS Media Info Update Frequency": "TTS 미디어 정보 업데이트 빈도",
"Select reading speed": "읽기 속도 선택",
"Drag to seek": "드래그하여 탐색",
"Punctuation Delay": "구두점 지연"
}
@@ -970,7 +970,6 @@
"words": "perkataan",
"{{time}} left": "{{time}} tinggal",
"Reading progress": "Kemajuan membaca",
"Click to seek": "Klik untuk cari",
"Skip back 15 words": "Langkau belakang 15 perkataan",
"Back 15 words (Shift+Left)": "Belakang 15 perkataan (Shift+Kiri)",
"Pause (Space)": "Jeda (Ruang)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "Setiap Ayat",
"Every Paragraph": "Setiap Perenggan",
"Every Chapter": "Setiap Bab",
"TTS Media Info Update Frequency": "Kekerapan Kemas Kini Info Media TTS"
"TTS Media Info Update Frequency": "Kekerapan Kemas Kini Info Media TTS",
"Select reading speed": "Pilih kelajuan membaca",
"Drag to seek": "Seret untuk mencari",
"Punctuation Delay": "Kelewatan tanda baca"
}
@@ -982,7 +982,6 @@
"words": "woorden",
"{{time}} left": "{{time}} over",
"Reading progress": "Leesvoortgang",
"Click to seek": "Klik om te zoeken",
"Skip back 15 words": "15 woorden terug",
"Back 15 words (Shift+Left)": "15 woorden terug (Shift+Links)",
"Pause (Space)": "Pauze (Spatie)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "Elke zin",
"Every Paragraph": "Elke alinea",
"Every Chapter": "Elk hoofdstuk",
"TTS Media Info Update Frequency": "TTS media-info updatefrequentie"
"TTS Media Info Update Frequency": "TTS media-info updatefrequentie",
"Select reading speed": "Leessnelheid selecteren",
"Drag to seek": "Sleep om te zoeken",
"Punctuation Delay": "Leesteken-vertraging"
}
@@ -1006,7 +1006,6 @@
"words": "słowa",
"{{time}} left": "pozostało {{time}}",
"Reading progress": "Postęp czytania",
"Click to seek": "Kliknij, aby wyszukać",
"Skip back 15 words": "Cofnij o 15 słów",
"Back 15 words (Shift+Left)": "Cofnij o 15 słów (Shift+Lewo)",
"Pause (Space)": "Pauze (Spacja)",
@@ -1177,5 +1176,8 @@
"Every Sentence": "Co zdanie",
"Every Paragraph": "Co akapit",
"Every Chapter": "Co rozdział",
"TTS Media Info Update Frequency": "Częstotliwość aktualizacji informacji o mediach TTS"
"TTS Media Info Update Frequency": "Częstotliwość aktualizacji informacji o mediach TTS",
"Select reading speed": "Wybierz prędkość czytania",
"Drag to seek": "Przeciągnij, aby przewinąć",
"Punctuation Delay": "Opóźnienie interpunkcji"
}
@@ -994,7 +994,6 @@
"words": "palavras",
"{{time}} left": "{{time}} restante",
"Reading progress": "Progresso de leitura",
"Click to seek": "Clique para navegar",
"Skip back 15 words": "Voltar 15 palavras",
"Back 15 words (Shift+Left)": "Voltar 15 palavras (Shift+Esquerda)",
"Pause (Space)": "Pausar (Espaço)",
@@ -1164,5 +1163,8 @@
"Every Sentence": "A cada frase",
"Every Paragraph": "A cada parágrafo",
"Every Chapter": "A cada capítulo",
"TTS Media Info Update Frequency": "Frequência de atualização de info de mídia TTS"
"TTS Media Info Update Frequency": "Frequência de atualização de info de mídia TTS",
"Select reading speed": "Selecionar velocidade de leitura",
"Drag to seek": "Arraste para procurar",
"Punctuation Delay": "Atraso de pontuação"
}
@@ -548,7 +548,6 @@
"words": "cuvinte",
"{{time}} left": "Timp rămas: {{time}}",
"Reading progress": "Progresul citirii",
"Click to seek": "Faceți clic pentru a derula",
"Skip back 15 words": "Săriți înapoi 15 cuvinte",
"Back 15 words (Shift+Left)": "Înapoi 15 cuvinte (Shift+Stânga)",
"Decrease speed": "Scade viteza",
@@ -1164,5 +1163,8 @@
"Every Sentence": "La fiecare propoziție",
"Every Paragraph": "La fiecare paragraf",
"Every Chapter": "La fiecare capitol",
"TTS Media Info Update Frequency": "Frecvența actualizării informațiilor media TTS"
"TTS Media Info Update Frequency": "Frecvența actualizării informațiilor media TTS",
"Select reading speed": "Selectați viteza de citire",
"Drag to seek": "Trageți pentru a căuta",
"Punctuation Delay": "Întârziere punctuație"
}
@@ -1006,7 +1006,6 @@
"words": "слов",
"{{time}} left": "осталось {{time}}",
"Reading progress": "Прогресс чтения",
"Click to seek": "Нажмите для поиска",
"Skip back 15 words": "Назад на 15 слов",
"Back 15 words (Shift+Left)": "Назад на 15 слов (Shift+Влево)",
"Pause (Space)": "Пауза (Пробел)",
@@ -1177,5 +1176,8 @@
"Every Sentence": "Каждое предложение",
"Every Paragraph": "Каждый абзац",
"Every Chapter": "Каждая глава",
"TTS Media Info Update Frequency": "Частота обновления медиа-информации TTS"
"TTS Media Info Update Frequency": "Частота обновления медиа-информации TTS",
"Select reading speed": "Выберите скорость чтения",
"Drag to seek": "Перетащите для перемотки",
"Punctuation Delay": "Задержка на знаках препинания"
}
@@ -982,7 +982,6 @@
"words": "වචන",
"{{time}} left": "{{time}} ඉතිරිව ඇත",
"Reading progress": "කියවීමේ ප්‍රගතිය",
"Click to seek": "සොයා බැලීමට ක්ලික් කරන්න",
"Skip back 15 words": "වචන 15ක් පසුපසට",
"Back 15 words (Shift+Left)": "වචන 15ක් පසුපසට (Shift+Left)",
"Pause (Space)": "විරාමය (Space)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "සෑම වාක්‍යයක්ම",
"Every Paragraph": "සෑම ඡේදයක්ම",
"Every Chapter": "සෑම පරිච්ඡේදයක්ම",
"TTS Media Info Update Frequency": "TTS මාධ්‍ය තොරතුරු යාවත්කාලීන සංඛ්‍යාතය"
"TTS Media Info Update Frequency": "TTS මාධ්‍ය තොරතුරු යාවත්කාලීන සංඛ්‍යාතය",
"Select reading speed": "කියවීමේ වේගය තෝරන්න",
"Drag to seek": "සෙවීමට ඇදගන්න",
"Punctuation Delay": "විරාම ලකුණු ප්‍රමාදය"
}
@@ -523,7 +523,6 @@
"words": "besed",
"{{time}} left": "{{time}} do konca",
"Reading progress": "Napredek branja",
"Click to seek": "Kliknite za iskanje",
"Skip back 15 words": "Skoči nazaj za 15 besed",
"Back 15 words (Shift+Left)": "Nazaj za 15 besed (Shift+Levo)",
"Pause": "Premor",
@@ -1177,5 +1176,8 @@
"Every Sentence": "Vsak stavek",
"Every Paragraph": "Vsak odstavek",
"Every Chapter": "Vsako poglavje",
"TTS Media Info Update Frequency": "Pogostost posodabljanja podatkov o medijih TTS"
"TTS Media Info Update Frequency": "Pogostost posodabljanja podatkov o medijih TTS",
"Select reading speed": "Izberite hitrost branja",
"Drag to seek": "Povlecite za iskanje",
"Punctuation Delay": "Zamik ločil"
}
@@ -982,7 +982,6 @@
"words": "ord",
"{{time}} left": "{{time}} kvar",
"Reading progress": "Läsningens framsteg",
"Click to seek": "Klicka för att söka",
"Skip back 15 words": "Hoppa bakåt 15 ord",
"Back 15 words (Shift+Left)": "Bakåt 15 ord (Shift+Vänster)",
"Pause (Space)": "Pausa (Mellanslag)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "Varje mening",
"Every Paragraph": "Varje stycke",
"Every Chapter": "Varje kapitel",
"TTS Media Info Update Frequency": "Uppdateringsfrekvens för TTS-medieinformation"
"TTS Media Info Update Frequency": "Uppdateringsfrekvens för TTS-medieinformation",
"Select reading speed": "Välj läshastighet",
"Drag to seek": "Dra för att söka",
"Punctuation Delay": "Skiljeteckenfördröjning"
}
@@ -982,7 +982,6 @@
"words": "வார்த்தைகள்",
"{{time}} left": "{{time}} மீதமுள்ளது",
"Reading progress": "வாசிப்பு முன்னேற்றம்",
"Click to seek": "தேட கிளிக் செய்க",
"Skip back 15 words": "15 வார்த்தைகள் பின்னால் செல்",
"Back 15 words (Shift+Left)": "15 வார்த்தைகள் பின்னால் செல் (Shift+Left)",
"Pause (Space)": "நிறுத்து (Space)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "ஒவ்வொரு வாக்கியமும்",
"Every Paragraph": "ஒவ்வொரு பத்தியும்",
"Every Chapter": "ஒவ்வொரு அத்தியாயமும்",
"TTS Media Info Update Frequency": "TTS ஊடக தகவல் புதுப்பிப்பு அதிர்வெண்"
"TTS Media Info Update Frequency": "TTS ஊடக தகவல் புதுப்பிப்பு அதிர்வெண்",
"Select reading speed": "படிக்கும் வேகத்தைத் தேர்ந்தெடுக்கவும்",
"Drag to seek": "தேட இழுக்கவும்",
"Punctuation Delay": "நிறுத்தக்குறி தாமதம்"
}
@@ -970,7 +970,6 @@
"words": "คำ",
"{{time}} left": "เหลือ {{time}}",
"Reading progress": "ความคืบหน้าการอ่าน",
"Click to seek": "คลิกเพื่อค้นหาตำแหน่ง",
"Skip back 15 words": "ย้อนกลับ 15 คำ",
"Back 15 words (Shift+Left)": "ย้อนกลับ 15 คำ (Shift+ซ้าย)",
"Pause (Space)": "หยุดชั่วคราว (Space)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "ทุกประโยค",
"Every Paragraph": "ทุกย่อหน้า",
"Every Chapter": "ทุกบท",
"TTS Media Info Update Frequency": "ความถี่ในการอัปเดตข้อมูลสื่อ TTS"
"TTS Media Info Update Frequency": "ความถี่ในการอัปเดตข้อมูลสื่อ TTS",
"Select reading speed": "เลือกความเร็วในการอ่าน",
"Drag to seek": "ลากเพื่อค้นหา",
"Punctuation Delay": "หน่วงเวลาเครื่องหมายวรรคตอน"
}
@@ -982,7 +982,6 @@
"words": "kelime",
"{{time}} left": "{{time}} kaldı",
"Reading progress": "Okuma ilerlemesi",
"Click to seek": "Konum aramak için tıkla",
"Skip back 15 words": "15 kelime geri atla",
"Back 15 words (Shift+Left)": "15 kelime geri (Shift+Sol)",
"Pause (Space)": "Duraklat (Boşluk)",
@@ -1151,5 +1150,8 @@
"Every Sentence": "Her Cümle",
"Every Paragraph": "Her Paragraf",
"Every Chapter": "Her Bölüm",
"TTS Media Info Update Frequency": "TTS Medya Bilgisi Güncelleme Sıklığı"
"TTS Media Info Update Frequency": "TTS Medya Bilgisi Güncelleme Sıklığı",
"Select reading speed": "Okuma hızını seçin",
"Drag to seek": "Aramak için sürükleyin",
"Punctuation Delay": "Noktalama gecikmesi"
}
@@ -1006,7 +1006,6 @@
"words": "слів",
"{{time}} left": "залишилося {{time}}",
"Reading progress": "Прогрес читання",
"Click to seek": "Натисніть для пошуку",
"Skip back 15 words": "Назад на 15 слів",
"Back 15 words (Shift+Left)": "Назад на 15 слів (Shift+Стрілка вліво)",
"Pause (Space)": "Пауза (Пробіл)",
@@ -1177,5 +1176,8 @@
"Every Sentence": "Кожне речення",
"Every Paragraph": "Кожен абзац",
"Every Chapter": "Кожен розділ",
"TTS Media Info Update Frequency": "Частота оновлення медіа-інформації TTS"
"TTS Media Info Update Frequency": "Частота оновлення медіа-інформації TTS",
"Select reading speed": "Виберіть швидкість читання",
"Drag to seek": "Перетягніть для пошуку",
"Punctuation Delay": "Затримка на розділових знаках"
}
@@ -970,7 +970,6 @@
"words": "từ",
"{{time}} left": "còn lại {{time}}",
"Reading progress": "Tiến độ đọc",
"Click to seek": "Nhấp để tua",
"Skip back 15 words": "Lùi lại 15 từ",
"Back 15 words (Shift+Left)": "Lùi lại 15 từ (Shift+Trái)",
"Pause (Space)": "Tạm dừng (Khoảng trắng)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "Mỗi câu",
"Every Paragraph": "Mỗi đoạn văn",
"Every Chapter": "Mỗi chương",
"TTS Media Info Update Frequency": "Tần suất cập nhật thông tin phương tiện TTS"
"TTS Media Info Update Frequency": "Tần suất cập nhật thông tin phương tiện TTS",
"Select reading speed": "Chọn tốc độ đọc",
"Drag to seek": "Kéo để tìm kiếm",
"Punctuation Delay": "Độ trễ dấu câu"
}
@@ -971,7 +971,6 @@
"words": "词",
"{{time}} left": "剩余 {{time}}",
"Reading progress": "阅读进度",
"Click to seek": "点击跳转",
"Skip back 15 words": "回退 15 词",
"Back 15 words (Shift+Left)": "回退 15 词 (Shift+Left)",
"Pause (Space)": "暂停 (空格)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "每句",
"Every Paragraph": "每段",
"Every Chapter": "每章",
"TTS Media Info Update Frequency": "TTS 媒体信息更新频率"
"TTS Media Info Update Frequency": "TTS 媒体信息更新频率",
"Select reading speed": "选择朗读速度",
"Drag to seek": "拖动以定位",
"Punctuation Delay": "标点符号停顿"
}
@@ -970,7 +970,6 @@
"words": "詞",
"{{time}} left": "剩餘 {{time}}",
"Reading progress": "閱讀進度",
"Click to seek": "點擊跳轉",
"Skip back 15 words": "回退 15 詞",
"Back 15 words (Shift+Left)": "回退 15 詞 (Shift+Left)",
"Pause (Space)": "暫停 (空格)",
@@ -1138,5 +1137,8 @@
"Every Sentence": "每句",
"Every Paragraph": "每段",
"Every Chapter": "每章",
"TTS Media Info Update Frequency": "TTS 媒體資訊更新頻率"
"TTS Media Info Update Frequency": "TTS 媒體資訊更新頻率",
"Select reading speed": "選擇朗讀速度",
"Drag to seek": "拖曳以定位",
"Punctuation Delay": "標點符號停頓"
}
@@ -0,0 +1,87 @@
import { describe, test, expect, vi } from 'vitest';
import { RSVPController } from '@/services/rsvp/RSVPController';
import { FoliateView } from '@/types/view';
function makeTextNode(text: string): Text {
return { nodeType: Node.TEXT_NODE, textContent: text } as unknown as Text;
}
function makeDoc(text: string): Document {
const textNode = makeTextNode(text);
const body = {
nodeType: Node.ELEMENT_NODE,
tagName: 'BODY',
childNodes: [textNode],
ownerDocument: null as unknown as Document,
} as unknown as HTMLElement;
const doc = {
body,
createRange: vi.fn().mockReturnValue({
setStart: vi.fn(),
setEnd: vi.fn(),
}),
defaultView: {
getComputedStyle: vi.fn().mockReturnValue({ display: 'block', visibility: 'visible' }),
},
} as unknown as Document;
(body as unknown as { ownerDocument: Document }).ownerDocument = doc;
(textNode as unknown as { ownerDocument: Document }).ownerDocument = doc;
return doc;
}
function createMockView(primaryIndex: number, docs: Document[]): FoliateView {
return {
renderer: {
primaryIndex,
getContents: vi.fn().mockReturnValue(docs.map((doc, i) => ({ doc, index: i }))),
},
book: { toc: [] },
language: { isCJK: false },
tts: null,
getCFI: vi.fn().mockReturnValue('epubcfi(/6/4!/4/2/1:0)'),
resolveCFI: vi.fn().mockReturnValue({ anchor: vi.fn().mockReturnValue(new Range()) }),
} as unknown as FoliateView;
}
describe('RSVPController', () => {
describe('start()', () => {
test('extracts words from primary spine document only', () => {
const ch1Doc = makeDoc('Hello world');
const ch2Doc = makeDoc('Foo bar baz');
const view = createMockView(0, [ch1Doc, ch2Doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
// Should only have words from doc at primaryIndex 0
expect(controller.currentState.words.length).toBe(2);
expect(controller.currentState.words[0]!.text).toBe('Hello');
expect(controller.currentState.words[1]!.text).toBe('world');
});
test('sets active state after start', () => {
const doc = makeDoc('one two three');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
expect(controller.currentState.active).toBe(true);
expect(controller.currentState.currentIndex).toBe(0);
});
test('uses secondary doc when primaryIndex is 1', () => {
const ch1Doc = makeDoc('Hello world');
const ch2Doc = makeDoc('Foo bar');
const view = createMockView(1, [ch1Doc, ch2Doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
expect(controller.currentState.words.length).toBe(2);
expect(controller.currentState.words[0]!.text).toBe('Foo');
});
});
});
@@ -103,12 +103,7 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => {
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const {
getView,
getProgress,
getViewSettings: _getViewSettings,
setProgress: _setProgress,
} = useReaderStore();
const { getView, getProgress } = useReaderStore();
const { getBookData } = useBookDataStore();
const { themeCode } = useThemeStore();
@@ -250,18 +245,16 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
// Navigate to the saved position's section
view.goTo(cfi);
// Wait for navigation, then reload and start RSVP
// Wait for navigation, then start RSVP — start() handles word extraction
// and position recovery from storage directly, so loadNextPageContent()
// must not be called here (it would clear the saved position first)
setTimeout(() => {
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
// Small delay to ensure content is loaded
setTimeout(() => {
controller.start();
setIsActive(true);
}, 100);
controller.start();
setIsActive(true);
}, 500);
}
};
@@ -390,11 +383,8 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const view = getView(bookKey);
if (!view) return;
// Navigate to chapter
view.goTo(href);
// Wait for navigation, then reload RSVP content
setTimeout(() => {
const onRelocate = () => {
view.removeEventListener('relocate', onRelocate);
const controller = controllerRef.current;
if (controller) {
const progress = getProgress(bookKey);
@@ -403,7 +393,9 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
}
controller.loadNextPageContent();
}
}, 500);
};
view.addEventListener('relocate', onRelocate);
view.goTo(href);
},
[bookKey, getProgress, getView],
);
@@ -412,25 +404,29 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const view = getView(bookKey);
if (!view) return;
// Remove RSVP highlight when moving to next page
removeRsvpHighlight();
// RSVP extracts ALL words from the current section via renderer.getContents().
// When RSVP runs out of words and calls this function, it means the entire
// chapter/section has been read, so we need to go to the next section.
await view.renderer.nextSection?.();
const indexBefore = view.renderer.primaryIndex;
// Wait for section change, then load new content
setTimeout(() => {
const onRelocate = () => {
view.removeEventListener('relocate', onRelocate);
const controller = controllerRef.current;
if (controller) {
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
if (!controller) return;
// Pause at the end of the book instead of advancing
if (view.renderer.primaryIndex === indexBefore) {
controller.pause();
return;
}
}, 500);
const progress = getProgress(bookKey);
if (progress?.location) {
controller.setCurrentCfi(progress.location);
}
controller.loadNextPageContent();
};
view.addEventListener('relocate', onRelocate);
await view.renderer.nextSection?.();
}, [bookKey, getProgress, getView, removeRsvpHighlight]);
// Get current chapter info
@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import clsx from 'clsx';
import { Insets } from '@/types/misc';
import { RsvpState, RsvpWord, RSVPController } from '@/services/rsvp';
import { RsvpState, RSVPController } from '@/services/rsvp';
import { useThemeStore } from '@/store/themeStore';
import { TOCItem } from '@/libs/document';
import {
@@ -56,9 +56,11 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const _ = useTranslation();
const { themeCode, isDarkMode: _isDarkMode } = useThemeStore();
const [state, setState] = useState<RsvpState>(controller.currentState);
const [currentWord, setCurrentWord] = useState<RsvpWord | null>(controller.currentWord);
const currentWord = state.words[state.currentIndex] ?? null;
const [countdown, setCountdown] = useState<number | null>(controller.currentCountdown);
const [showChapterDropdown, setShowChapterDropdown] = useState(false);
const chapterDropdownRef = useRef<HTMLDivElement>(null);
const [showWpmDropdown, setShowWpmDropdown] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [contextCollapsed, setContextCollapsed] = useState(() => {
try {
@@ -105,6 +107,9 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const touchStartX = useRef(0);
const touchStartY = useRef(0);
const touchStartTime = useRef(0);
const isDraggingProgressBar = useRef(false);
const wasPlayingBeforeDrag = useRef(false);
const [isProgressBarDragging, setIsProgressBarDragging] = useState(false);
const SWIPE_THRESHOLD = 50;
const TAP_THRESHOLD = 10;
@@ -128,7 +133,6 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const handleStateChange = (e: Event) => {
const newState = (e as CustomEvent<RsvpState>).detail;
setState(newState);
setCurrentWord(controller.currentWord);
// Update context window only when current word falls outside or nears edge
const idx = newState.currentIndex;
@@ -223,6 +227,8 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
return () => document.removeEventListener('keydown', handleKeyboard, { capture: true });
}, [state.active, controller, onClose]);
const effectiveChapterHref = currentChapterHref;
// Word display helpers
const wordBefore = currentWord ? currentWord.text.substring(0, currentWord.orpIndex) : '';
const orpChar = currentWord ? currentWord.text.charAt(currentWord.orpIndex) : '';
@@ -272,6 +278,19 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
}, [state.currentIndex, contextCollapsed]);
useEffect(() => {
if (!showChapterDropdown) return;
const raf = requestAnimationFrame(() => {
const container = chapterDropdownRef.current;
if (!container) return;
const activeItem = container.querySelector<HTMLElement>('[data-active="true"]');
if (activeItem) {
activeItem.scrollIntoView({ block: 'center' });
}
});
return () => cancelAnimationFrame(raf);
}, [showChapterDropdown]);
const toggleContext = useCallback(() => {
setContextCollapsed((prev) => {
const next = !prev;
@@ -305,23 +324,26 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
// Chapter helpers
const getCurrentChapterLabel = useCallback((): string => {
if (!currentChapterHref) return _('Select Chapter');
const normalizedCurrent = currentChapterHref.split('#')[0]?.replace(/^\//, '') || '';
if (!effectiveChapterHref) return _('Select Chapter');
const exactMatch = flatChapters.find((c) => c.href === effectiveChapterHref);
if (exactMatch) return exactMatch.label;
const normalizedCurrent = effectiveChapterHref.split('#')[0]?.replace(/^\//, '') || '';
const chapter = flatChapters.find((c) => {
const normalizedHref = c.href.split('#')[0]?.replace(/^\//, '') || '';
return normalizedHref === normalizedCurrent;
});
return chapter?.label || _('Select Chapter');
}, [_, currentChapterHref, flatChapters]);
}, [_, effectiveChapterHref, flatChapters]);
const isChapterActive = useCallback(
(href: string): boolean => {
if (!currentChapterHref) return false;
const normalizedCurrent = currentChapterHref.split('#')[0]?.replace(/^\//, '') || '';
if (!effectiveChapterHref) return false;
if (href === effectiveChapterHref) return true;
const normalizedCurrent = effectiveChapterHref.split('#')[0]?.replace(/^\//, '') || '';
const normalizedHref = href.split('#')[0]?.replace(/^\//, '') || '';
return normalizedHref === normalizedCurrent;
},
[currentChapterHref],
[effectiveChapterHref],
);
// Touch handlers
@@ -369,23 +391,39 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}
};
// Progress bar click handler
const handleProgressBarClick = (event: React.MouseEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const percentage = (clickX / rect.width) * 100;
const handleWordClick = (wordIndex: number) => {
const wasPlaying = state.playing;
if (wasPlaying) {
controller.pause();
}
if (wasPlaying) controller.pause();
controller.seekToIndex(wordIndex);
if (wasPlaying) setTimeout(() => controller.resume(), 50);
};
controller.seekToPosition(percentage);
const getProgressBarPercentage = (clientX: number, target: HTMLElement): number => {
const rect = target.getBoundingClientRect();
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
};
if (wasPlaying) {
setTimeout(() => controller.resume(), 50);
}
const handleProgressBarPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
isDraggingProgressBar.current = true;
setIsProgressBarDragging(true);
wasPlayingBeforeDrag.current = state.playing;
if (state.playing) controller.pause();
controller.seekToPosition(getProgressBarPercentage(event.clientX, event.currentTarget));
};
const handleProgressBarPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingProgressBar.current) return;
controller.seekToPosition(getProgressBarPercentage(event.clientX, event.currentTarget));
};
const handleProgressBarPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingProgressBar.current) return;
isDraggingProgressBar.current = false;
setIsProgressBarDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
if (wasPlayingBeforeDrag.current) setTimeout(() => controller.resume(), 50);
};
const handleChapterSelect = (href: string) => {
@@ -428,7 +466,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<button
aria-label={_('Close Speed Reading')}
title={_('Close')}
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-gray-500/20 active:scale-95'
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-gray-500/20'
onClick={onClose}
>
<IoClose className='h-5 w-5' />
@@ -437,7 +475,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
{/* Chapter selector */}
<div className='relative min-w-0 flex-1'>
<button
className='flex w-full items-center gap-1.5 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm transition-colors hover:bg-gray-500/20 active:scale-[0.98]'
className='flex w-full items-center gap-1.5 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm transition-colors hover:bg-gray-500/20'
onClick={() => setShowChapterDropdown(!showChapterDropdown)}
>
<span className='min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left'>
@@ -457,12 +495,14 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<>
<Overlay onDismiss={() => setShowChapterDropdown(false)} />
<div
ref={chapterDropdownRef}
className='absolute left-0 right-0 top-full z-[100] mt-1.5 max-h-64 overflow-y-auto rounded-2xl border border-gray-500/20 px-2 shadow-2xl'
style={{ backgroundColor: bgColor }}
>
{flatChapters.map((chapter, idx) => (
<button
key={`${chapter.href}-${idx}`}
data-active={isChapterActive(chapter.href) ? 'true' : undefined}
className={clsx(
'block w-full rounded-md border-none bg-transparent px-4 py-2.5 text-left text-sm transition-colors first:rounded-t-2xl last:rounded-b-2xl hover:bg-gray-500/15',
isChapterActive(chapter.href) &&
@@ -479,10 +519,53 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
)}
</div>
{/* WPM badge */}
<div className='shrink-0 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm tabular-nums'>
<span className='font-semibold'>{state.wpm}</span>
<span className='ml-0.5 text-xs opacity-50'>WPM</span>
{/* WPM selector */}
<div className='relative shrink-0'>
<button
className='flex items-center gap-1 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm tabular-nums transition-colors hover:bg-gray-500/20'
onClick={() => setShowWpmDropdown(!showWpmDropdown)}
aria-label={_('Select reading speed')}
title={_('Select reading speed')}
>
<span className='font-semibold'>{state.wpm}</span>
<span className='ml-0.5 text-xs opacity-50'>WPM</span>
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
className='ml-0.5 h-3 w-3 shrink-0 opacity-50'
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
{showWpmDropdown && (
<>
<Overlay onDismiss={() => setShowWpmDropdown(false)} />
<div
className='absolute right-0 top-full z-[100] mt-1.5 max-h-64 min-w-[7rem] overflow-y-auto rounded-2xl border border-gray-500/20 shadow-2xl'
style={{ backgroundColor: bgColor }}
>
{controller.getWpmOptions().map((wpm) => (
<button
key={wpm}
className={clsx(
'flex w-full items-center justify-between gap-3 whitespace-nowrap rounded-md border-none bg-transparent px-4 py-1.5 text-sm tabular-nums transition-colors first:rounded-t-2xl last:rounded-b-2xl hover:bg-gray-500/15',
state.wpm === wpm &&
'bg-[color-mix(in_srgb,var(--rsvp-accent)_15%,transparent)] font-semibold',
)}
onClick={() => {
controller.setWpm(wpm);
setShowWpmDropdown(false);
}}
>
<span>{wpm}</span>
<span className='text-xs opacity-40'>WPM</span>
</button>
))}
</div>
</>
)}
</div>
</div>
@@ -517,6 +600,8 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<div
ref={contextPanelRef}
className='max-h-[20vh] overflow-y-auto px-3 pb-3 md:px-4 md:pb-4'
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
<div className='text-left text-base leading-relaxed md:text-lg'>
{contextWords.map((w, i) => {
@@ -526,8 +611,20 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<span
key={wordIndex}
ref={isCurrent ? contextWordRef : undefined}
className={isCurrent ? undefined : 'opacity-70'}
role={isCurrent ? undefined : 'button'}
tabIndex={isCurrent ? undefined : 0}
className={
isCurrent ? undefined : 'cursor-pointer opacity-70 hover:opacity-100'
}
style={isCurrent ? { color: effectiveOrpColor } : undefined}
onClick={isCurrent ? undefined : () => handleWordClick(wordIndex)}
onKeyDown={
isCurrent
? undefined
: (e) => {
if (e.key === 'Enter' || e.key === ' ') handleWordClick(wordIndex);
}
}
>
{w.text}{' '}
</span>
@@ -618,21 +715,24 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
aria-valuemin={0}
aria-valuemax={100}
className='relative h-2 cursor-pointer overflow-visible rounded bg-gray-500/30'
onClick={handleProgressBarClick}
onPointerDown={handleProgressBarPointerDown}
onPointerMove={handleProgressBarPointerMove}
onPointerUp={handleProgressBarPointerUp}
onPointerCancel={handleProgressBarPointerUp}
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'ArrowLeft') controller.skipBackward();
else if (e.key === 'ArrowRight') controller.skipForward();
}}
title={_('Click to seek')}
title={_('Drag to seek')}
>
<div
className='absolute left-0 top-0 h-full rounded transition-[width] duration-100'
className={`absolute left-0 top-0 h-full rounded ${isProgressBarDragging ? '' : 'transition-[width] duration-100'}`}
style={{ width: `${state.progress}%`, backgroundColor: accentColor }}
/>
<div
className='absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full shadow transition-[left] duration-100'
className={`absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full shadow ${isProgressBarDragging ? '' : 'transition-[left] duration-100'}`}
style={{ left: `${state.progress}%`, backgroundColor: accentColor }}
/>
</div>
@@ -712,7 +812,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<div className='mt-3 flex flex-wrap items-center justify-evenly gap-x-8 gap-y-4 text-xs md:justify-center'>
{/* Punctuation pause */}
<label className='flex cursor-pointer items-center gap-1.5 font-medium opacity-80'>
<span className='mr-0.5 font-medium opacity-50'>{_('Pause')}</span>
<span className='mr-0.5 font-medium opacity-50'>{_('Punctuation Delay')}</span>
<select
className='cursor-pointer rounded border border-gray-500/30 bg-gray-500/20 px-1.5 py-1 text-xs font-medium transition-colors hover:border-gray-500/40 hover:bg-gray-500/30'
style={{ color: 'inherit' }}
@@ -16,7 +16,6 @@ const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
export class RSVPController extends EventTarget {
private view: FoliateView;
private bookKey: string;
private bookId: string; // Book hash without session suffix, for persistent storage
private currentCfi: string | null = null;
@@ -28,7 +27,6 @@ export class RSVPController extends EventTarget {
wpm: DEFAULT_WPM,
punctuationPauseMs: DEFAULT_PUNCTUATION_PAUSE_MS,
progress: 0,
resumedFromIndex: null,
};
private playbackTimer: ReturnType<typeof setTimeout> | null = null;
@@ -39,7 +37,6 @@ export class RSVPController extends EventTarget {
constructor(view: FoliateView, bookKey: string) {
super();
this.view = view;
this.bookKey = bookKey;
// Extract book ID (hash) from bookKey format: "{hash}-{sessionId}"
// Use only the hash for persistent position storage across sessions
this.bookId = bookKey.split('-')[0] || bookKey;
@@ -58,7 +55,11 @@ export class RSVPController extends EventTarget {
}
get currentState(): RsvpState {
return { ...this.state };
return {
...this.state,
progress:
this.state.words.length > 0 ? (this.state.currentIndex / this.state.words.length) * 100 : 0,
};
}
get currentWord(): RsvpWord | null {
@@ -76,6 +77,14 @@ export class RSVPController extends EventTarget {
return PUNCTUATION_PAUSE_OPTIONS;
}
getWpmOptions(): number[] {
const options: number[] = [];
for (let wpm = MIN_WPM; wpm <= MAX_WPM; wpm += WPM_STEP) {
options.push(wpm);
}
return options;
}
setPunctuationPause(pauseMs: number): void {
if (PUNCTUATION_PAUSE_OPTIONS.includes(pauseMs)) {
this.state.punctuationPauseMs = pauseMs;
@@ -85,7 +94,7 @@ export class RSVPController extends EventTarget {
}
private loadPunctuationPauseFromStorage(): number | null {
const stored = localStorage.getItem(`${PUNCTUATION_PAUSE_KEY_PREFIX}${this.bookKey}`);
const stored = localStorage.getItem(`${PUNCTUATION_PAUSE_KEY_PREFIX}${this.bookId}`);
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && PUNCTUATION_PAUSE_OPTIONS.includes(parsed)) {
@@ -96,7 +105,7 @@ export class RSVPController extends EventTarget {
}
private savePunctuationPauseToStorage(pauseMs: number): void {
localStorage.setItem(`${PUNCTUATION_PAUSE_KEY_PREFIX}${this.bookKey}`, pauseMs.toString());
localStorage.setItem(`${PUNCTUATION_PAUSE_KEY_PREFIX}${this.bookId}`, pauseMs.toString());
}
setWpm(wpm: number): void {
@@ -107,7 +116,7 @@ export class RSVPController extends EventTarget {
}
private loadWpmFromStorage(): number | null {
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${this.bookKey}`);
const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${this.bookId}`);
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed >= MIN_WPM && parsed <= MAX_WPM) {
@@ -118,7 +127,7 @@ export class RSVPController extends EventTarget {
}
private saveWpmToStorage(wpm: number): void {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${this.bookKey}`, wpm.toString());
localStorage.setItem(`${STORAGE_KEY_PREFIX}${this.bookId}`, wpm.toString());
}
setCurrentCfi(cfi: string | null): void {
@@ -126,7 +135,6 @@ export class RSVPController extends EventTarget {
}
private loadPositionFromStorage(): RsvpPosition | null {
// Use bookId (without session suffix) for persistent position across sessions
const stored = localStorage.getItem(`${POSITION_KEY_PREFIX}${this.bookId}`);
if (stored) {
try {
@@ -144,20 +152,17 @@ export class RSVPController extends EventTarget {
const currentWord = this.state.words[this.state.currentIndex];
if (!currentWord) return;
// Use the word's CFI if available, otherwise fall back to section CFI
const cfi = currentWord.cfi || this.currentCfi;
if (!cfi) return;
const position: RsvpPosition = {
cfi: cfi,
cfi,
wordText: currentWord.text,
};
// Use bookId (without session suffix) for persistent position across sessions
localStorage.setItem(`${POSITION_KEY_PREFIX}${this.bookId}`, JSON.stringify(position));
}
private clearPositionFromStorage(): void {
// Use bookId (without session suffix) for persistent position across sessions
localStorage.removeItem(`${POSITION_KEY_PREFIX}${this.bookId}`);
}
@@ -179,28 +184,18 @@ export class RSVPController extends EventTarget {
private findWordIndexByCfi(words: RsvpWord[], targetCfi: string): number {
// First try exact CFI match
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (word?.cfi === targetCfi) {
return i;
}
if (words[i]?.cfi === targetCfi) return i;
}
// Check if target is in same section as any word
// Find the first word at or after the target position using CFI compare
const targetSpineIndex = this.getSpineIndex(targetCfi);
if (targetSpineIndex < 0) return -1;
// Find the first word at or after the target position using CFI compare
for (let i = 0; i < words.length; i++) {
const word = words[i];
if (!word?.cfi) continue;
// Must be in the same section
if (this.getSpineIndex(word.cfi) !== targetSpineIndex) continue;
// Use compareCFI to find first word at or after target
if (compareCFI(word.cfi, targetCfi) >= 0) {
return i;
}
if (compareCFI(word.cfi, targetCfi) >= 0) return i;
}
return -1;
@@ -217,53 +212,36 @@ export class RSVPController extends EventTarget {
}
let startIndex = 0;
let resumedFromIndex: number | null = null;
if (this.pendingStartWordIndex !== null && this.pendingStartWordIndex < words.length) {
startIndex = this.pendingStartWordIndex;
this.pendingStartWordIndex = null;
} else {
const savedPosition = this.loadPositionFromStorage();
if (savedPosition) {
// Try CFI-based position recovery first
if (savedPosition.cfi) {
const cfiIndex = this.findWordIndexByCfi(words, savedPosition.cfi);
if (cfiIndex >= 0) {
startIndex = cfiIndex;
resumedFromIndex = cfiIndex;
} else {
// CFI not found, try text match as fallback
const textMatchIndex = words.findIndex((w) => w.text === savedPosition.wordText);
if (textMatchIndex >= 0) {
startIndex = textMatchIndex;
resumedFromIndex = textMatchIndex;
}
}
if (savedPosition?.cfi) {
const cfiIndex = this.findWordIndexByCfi(words, savedPosition.cfi);
if (cfiIndex >= 0) {
startIndex = cfiIndex;
} else {
// Legacy position without CFI - try text match
const textMatchIndex = words.findIndex((w) => w.text === savedPosition.wordText);
if (textMatchIndex >= 0) {
startIndex = textMatchIndex;
resumedFromIndex = textMatchIndex;
const textIndex = words.findIndex((w) => w.text === savedPosition.wordText);
if (textIndex >= 0) {
startIndex = textIndex;
}
}
}
}
const clampedStart = words.length > 0 ? Math.min(words.length - 1, Math.max(0, startIndex)) : 0;
this.state = {
...this.state,
active: true,
playing: false,
playing: true,
words,
currentIndex: startIndex,
progress: (startIndex / words.length) * 100,
resumedFromIndex,
currentIndex: clampedStart,
};
this.emitStateChange();
this.startCountdown(() => {
this.state.playing = true;
this.emitStateChange();
this.scheduleNextWord();
});
}
@@ -277,9 +255,9 @@ export class RSVPController extends EventTarget {
resume(): void {
if (!this.state.active) return;
this.state.playing = true;
this.emitStateChange();
this.startCountdown(() => {
this.state.playing = true;
this.emitStateChange();
this.scheduleNextWord();
});
}
@@ -325,7 +303,6 @@ export class RSVPController extends EventTarget {
let stopPosition: RsvpStopPosition | null = null;
if (this.state.words.length > 0) {
const currentWord = this.state.words[this.state.currentIndex];
stopPosition = {
wordIndex: this.state.currentIndex,
totalWords: this.state.words.length,
@@ -346,27 +323,24 @@ export class RSVPController extends EventTarget {
playing: false,
words: [],
currentIndex: 0,
progress: 0,
resumedFromIndex: null,
};
this.emitStateChange();
}
requestStart(selectionText?: string): void {
const savedPosition = this.loadPositionFromStorage();
// Show Resume option if we have a saved position with a valid CFI
// We don't require it to be in the same section - user may want to resume
// from where they left off even if they've navigated elsewhere
const hasSavedPosition = !!savedPosition?.cfi;
const hasSelection = !!selectionText && selectionText.trim().length > 0;
const startChoice: RsvpStartChoice = {
hasSavedPosition,
hasSelection,
selectionText: selectionText?.trim(),
};
this.dispatchEvent(new CustomEvent('rsvp-start-choice', { detail: startChoice }));
this.dispatchEvent(
new CustomEvent('rsvp-start-choice', {
detail: {
hasSavedPosition,
hasSelection,
selectionText: selectionText?.trim(),
} as RsvpStartChoice,
}),
);
}
startFromBeginning(): void {
@@ -378,19 +352,13 @@ export class RSVPController extends EventTarget {
startFromSavedPosition(): void {
const savedPosition = this.loadPositionFromStorage();
if (!savedPosition?.cfi) {
// No saved position, start from beginning
this.start();
return;
}
// Check if saved position is in a different section
if (!this.isSameSection(savedPosition.cfi, this.currentCfi)) {
// Need to navigate to the saved section first
// Emit event for React component to handle navigation
this.dispatchEvent(
new CustomEvent('rsvp-navigate-to-resume', {
detail: { cfi: savedPosition.cfi },
}),
new CustomEvent('rsvp-navigate-to-resume', { detail: { cfi: savedPosition.cfi } }),
);
return;
}
@@ -403,13 +371,10 @@ export class RSVPController extends EventTarget {
this.clearPositionFromStorage();
const words = this.extractWordsWithRanges();
// Use CFI-based matching to find the first word at current page position
let startIndex = 0;
if (this.currentCfi) {
const cfiIndex = this.findWordIndexByCfi(words, this.currentCfi);
if (cfiIndex >= 0) {
startIndex = cfiIndex;
}
if (cfiIndex >= 0) startIndex = cfiIndex;
}
this.pendingStartWordIndex = startIndex > 0 ? startIndex : null;
@@ -435,7 +400,6 @@ export class RSVPController extends EventTarget {
if (hasCJK) {
const selectionLower = cleanSelection.toLowerCase();
// Build a continuous text from words for matching
for (let i = 0; i < words.length; i++) {
let continuousText = '';
for (let j = i; j < Math.min(i + 20, words.length); j++) {
@@ -446,7 +410,6 @@ export class RSVPController extends EventTarget {
}
}
// Fallback: try to match first few characters
const firstChars = cleanSelection.slice(0, Math.min(3, cleanSelection.length)).toLowerCase();
for (let i = 0; i < words.length; i++) {
if (words[i]!.text.toLowerCase().includes(firstChars)) {
@@ -473,9 +436,7 @@ export class RSVPController extends EventTarget {
cleanWord.includes(cleanFirstWord) ||
cleanFirstWord.includes(cleanWord)
) {
if (selectionWords.length === 1) {
return i;
}
if (selectionWords.length === 1) return i;
let matchCount = 1;
for (let j = 1; j < selectionWords.length && i + j < words.length; j++) {
@@ -488,9 +449,7 @@ export class RSVPController extends EventTarget {
}
}
if (matchCount >= Math.ceil(selectionWords.length / 2)) {
return i;
}
if (matchCount >= Math.ceil(selectionWords.length / 2)) return i;
}
}
@@ -512,31 +471,33 @@ export class RSVPController extends EventTarget {
}
skipForward(count: number = 10): void {
const newIndex = Math.min(this.state.words.length - 1, this.state.currentIndex + count);
this.state.currentIndex = newIndex;
this.state.progress = (newIndex / this.state.words.length) * 100;
this.state.currentIndex = Math.min(
this.state.words.length - 1,
this.state.currentIndex + count,
);
this.emitStateChange();
}
skipBackward(count: number = 10): void {
const newIndex = Math.max(0, this.state.currentIndex - count);
this.state.currentIndex = newIndex;
this.state.progress = (newIndex / this.state.words.length) * 100;
this.state.currentIndex = Math.max(0, this.state.currentIndex - count);
this.emitStateChange();
}
seekToPosition(percentage: number): void {
if (this.state.words.length === 0) return;
const newIndex = Math.floor((percentage / 100) * this.state.words.length);
const clampedIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex));
this.state.currentIndex = clampedIndex;
this.state.progress = (clampedIndex / this.state.words.length) * 100;
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex));
this.emitStateChange();
}
seekToIndex(index: number): void {
if (this.state.words.length === 0) return;
this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, index));
this.emitStateChange();
}
loadNextPageContent(retryCount = 0): void {
this.clearPositionFromStorage();
this.clearTimer();
const words = this.extractWordsWithRanges();
if (words.length === 0) {
if (retryCount < 3) {
@@ -548,21 +509,18 @@ export class RSVPController extends EventTarget {
}
const wasPlaying = this.state.playing;
this.state = {
...this.state,
playing: false,
words,
currentIndex: 0,
progress: 0,
resumedFromIndex: null,
playing: false,
};
this.emitStateChange();
if (wasPlaying) {
this.state.playing = true;
this.emitStateChange();
this.startCountdown(() => {
this.state.playing = true;
this.emitStateChange();
this.scheduleNextWord();
});
}
@@ -595,7 +553,6 @@ export class RSVPController extends EventTarget {
}
this.state.currentIndex = newIndex;
this.state.progress = (newIndex / this.state.words.length) * 100;
this.emitStateChange();
this.scheduleNextWord();
@@ -615,17 +572,14 @@ export class RSVPController extends EventTarget {
const contents = renderer.getContents?.();
if (!contents || contents.length === 0) return [];
const allWords: RsvpWord[] = [];
// Only process the primary spine section (one section at a time)
const primary = contents.find((c) => c.index === renderer.primaryIndex) ?? contents[0];
if (!primary) return [];
for (const content of contents) {
const { doc, index: docIndex } = content as { doc: Document; index: number };
if (!doc?.body) continue;
const { doc, index: docIndex } = primary as { doc: Document; index: number };
if (!doc?.body) return [];
const words = this.extractWordsFromElement(doc.body, doc, docIndex);
allWords.push(...words);
}
return allWords;
return this.extractWordsFromElement(doc.body, doc, docIndex);
}
private extractWordsFromElement(
@@ -681,21 +635,13 @@ export class RSVPController extends EventTarget {
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as HTMLElement;
const tagName = el.tagName.toUpperCase();
if (excludeTags.has(tagName)) {
return;
}
if (excludeTags.has(el.tagName.toUpperCase())) return;
const style = el.ownerDocument.defaultView?.getComputedStyle(el);
if (style?.display === 'none' || style?.visibility === 'hidden') {
return;
}
if (style?.display === 'none' || style?.visibility === 'hidden') return;
for (const child of Array.from(el.childNodes)) {
walk(child);
@@ -711,8 +657,7 @@ export class RSVPController extends EventTarget {
if (hasCJK) {
// For CJK characters, center the ORP since each character is more balanced
const len = word.length;
return Math.floor(len / 2);
return Math.floor(word.length / 2);
}
const cleanWord = word.replace(/[^\w]/g, '');
@@ -15,7 +15,6 @@ export interface RsvpState {
wpm: number;
punctuationPauseMs: number;
progress: number;
resumedFromIndex: number | null;
}
export interface RsvpPosition {