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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user