From c9647276b13d56e182217323e7b4a9703cfebdad Mon Sep 17 00:00:00 2001 From: Lex Moulton Date: Wed, 1 Apr 2026 18:31:58 -0700 Subject: [PATCH] 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 --- .../public/locales/ar/translation.json | 6 +- .../public/locales/bn/translation.json | 6 +- .../public/locales/bo/translation.json | 6 +- .../public/locales/de/translation.json | 6 +- .../public/locales/el/translation.json | 6 +- .../public/locales/es/translation.json | 6 +- .../public/locales/fa/translation.json | 6 +- .../public/locales/fr/translation.json | 6 +- .../public/locales/he/translation.json | 6 +- .../public/locales/hi/translation.json | 6 +- .../public/locales/id/translation.json | 6 +- .../public/locales/it/translation.json | 6 +- .../public/locales/ja/translation.json | 6 +- .../public/locales/ko/translation.json | 6 +- .../public/locales/ms/translation.json | 6 +- .../public/locales/nl/translation.json | 6 +- .../public/locales/pl/translation.json | 6 +- .../public/locales/pt/translation.json | 6 +- .../public/locales/ro/translation.json | 6 +- .../public/locales/ru/translation.json | 6 +- .../public/locales/si/translation.json | 6 +- .../public/locales/sl/translation.json | 6 +- .../public/locales/sv/translation.json | 6 +- .../public/locales/ta/translation.json | 6 +- .../public/locales/th/translation.json | 6 +- .../public/locales/tr/translation.json | 6 +- .../public/locales/uk/translation.json | 6 +- .../public/locales/vi/translation.json | 6 +- .../public/locales/zh-CN/translation.json | 6 +- .../public/locales/zh-TW/translation.json | 6 +- .../services/rsvp-controller.test.ts | 87 ++++++++ .../reader/components/rsvp/RSVPControl.tsx | 62 +++--- .../reader/components/rsvp/RSVPOverlay.tsx | 170 ++++++++++++--- .../src/services/rsvp/RSVPController.ts | 199 +++++++----------- apps/readest-app/src/services/rsvp/types.ts | 1 - 35 files changed, 443 insertions(+), 256 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/rsvp-controller.test.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index e0ce1a6a..3a2d465a 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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": "تأخير علامات الترقيم" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 080b967e..d7555b48 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -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": "বিরাম চিহ্ন বিলম্ব" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 50fcd46a..5701db64 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -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": "ཚེག་ཤད་ཕྱིར་འགྱངས།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 3c9357e6..7e0749f4 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index cb907501..29e0f904 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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": "Καθυστέρηση στίξης" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index e803e062..e90c298d 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 501668c0..90945b9b 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -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": "تأخیر نشانه‌گذاری" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index c9df7cd4..4a8b41e7 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index ec997b4f..9ae1befe 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -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": "השהיית פיסוק" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 65e70bef..34fdcbad 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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": "विराम चिह्न विलंब" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index bf5bc420..c5b55fc5 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 4409cf8d..41a0095a 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index d5a882cd..4e99c442 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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": "句読点の遅延" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index b3eb410d..9d05c09c 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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": "구두점 지연" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index d2ffa377..03fc23ae 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index d0cffbd2..c9f7406c 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 3fbb8102..ec2db96c 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 92c216e5..e28a4add 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index e72254dd..981a87f7 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index f3ac4c42..8e31891d 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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": "Задержка на знаках препинания" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 0076360b..f905b9d5 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -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": "විරාම ලකුණු ප්‍රමාදය" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index a019879a..e63cd645 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 948748c5..39e36060 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index a487b053..a102455d 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -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": "நிறுத்தக்குறி தாமதம்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 8da211b9..bdbe1429 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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": "หน่วงเวลาเครื่องหมายวรรคตอน" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 5d792de2..cb0f9816 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index e2ab16e4..bf8fdb4e 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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": "Затримка на розділових знаках" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index ede64115..9e801efe 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index e1af5abb..e3591201 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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": "标点符号停顿" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index f8562704..014666d9 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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": "標點符號停頓" } diff --git a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts new file mode 100644 index 00000000..f56171f6 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts @@ -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'); + }); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx index e075d3b6..c57b007d 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPControl.tsx @@ -103,12 +103,7 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => { const RSVPControl: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ bookKey, gridInsets }) => { } controller.loadNextPageContent(); } - }, 500); + }; + view.addEventListener('relocate', onRelocate); + view.goTo(href); }, [bookKey, getProgress, getView], ); @@ -412,25 +404,29 @@ const RSVPControl: React.FC = ({ 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 diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx index ccbfc471..4beb9481 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -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 = ({ const _ = useTranslation(); const { themeCode, isDarkMode: _isDarkMode } = useThemeStore(); const [state, setState] = useState(controller.currentState); - const [currentWord, setCurrentWord] = useState(controller.currentWord); + const currentWord = state.words[state.currentIndex] ?? null; const [countdown, setCountdown] = useState(controller.currentCountdown); const [showChapterDropdown, setShowChapterDropdown] = useState(false); + const chapterDropdownRef = useRef(null); + const [showWpmDropdown, setShowWpmDropdown] = useState(false); const [showSettings, setShowSettings] = useState(false); const [contextCollapsed, setContextCollapsed] = useState(() => { try { @@ -105,6 +107,9 @@ const RSVPOverlay: React.FC = ({ 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 = ({ const handleStateChange = (e: Event) => { const newState = (e as CustomEvent).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 = ({ 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 = ({ } }, [state.currentIndex, contextCollapsed]); + useEffect(() => { + if (!showChapterDropdown) return; + const raf = requestAnimationFrame(() => { + const container = chapterDropdownRef.current; + if (!container) return; + const activeItem = container.querySelector('[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 = ({ // 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 = ({ } }; - // 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) => { + 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) => { + if (!isDraggingProgressBar.current) return; + controller.seekToPosition(getProgressBarPercentage(event.clientX, event.currentTarget)); + }; + + const handleProgressBarPointerUp = (event: React.PointerEvent) => { + 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 = ({ + {showWpmDropdown && ( + <> + setShowWpmDropdown(false)} /> +
+ {controller.getWpmOptions().map((wpm) => ( + + ))} +
+ + )} @@ -517,6 +600,8 @@ const RSVPOverlay: React.FC = ({
e.stopPropagation()} + onTouchEnd={(e) => e.stopPropagation()} >
{contextWords.map((w, i) => { @@ -526,8 +611,20 @@ const RSVPOverlay: React.FC = ({ handleWordClick(wordIndex)} + onKeyDown={ + isCurrent + ? undefined + : (e) => { + if (e.key === 'Enter' || e.key === ' ') handleWordClick(wordIndex); + } + } > {w.text}{' '} @@ -618,21 +715,24 @@ const RSVPOverlay: React.FC = ({ 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')} >
@@ -712,7 +812,7 @@ const RSVPOverlay: React.FC = ({
{/* Punctuation pause */}