diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 55851500..b71b353b 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -2009,5 +2009,11 @@ "Open Read Aloud player": "فتح مشغل القراءة بصوت عالٍ", "Read Aloud": "القراءة بصوت عالٍ", "Voice": "الصوت", - "Sleep Timer": "مؤقت النوم" + "Sleep Timer": "مؤقت النوم", + "Slower": "أبطأ", + "Faster": "أسرع", + "Exit Auto Scroll": "الخروج من التمرير التلقائي", + "Auto Scroll": "التمرير التلقائي", + "End of book": "نهاية الكتاب", + "Toggle Auto Scroll": "تبديل التمرير التلقائي" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index e6403604..ce3f8e36 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "জোরে পড়ার প্লেয়ার খুলুন", "Read Aloud": "জোরে পড়ুন", "Voice": "কণ্ঠস্বর", - "Sleep Timer": "স্লিপ টাইমার" + "Sleep Timer": "স্লিপ টাইমার", + "Slower": "ধীর", + "Faster": "দ্রুত", + "Exit Auto Scroll": "স্বয়ংক্রিয় স্ক্রল থেকে প্রস্থান", + "Auto Scroll": "স্বয়ংক্রিয় স্ক্রল", + "End of book": "বইয়ের শেষ", + "Toggle Auto Scroll": "স্বয়ংক্রিয় স্ক্রল টগল করুন" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index d05f7569..efa8f4c1 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "སྐད་སྒྲས་ཀློག་པའི་གཏོང་ཆས་ཁ་ཕྱེ།", "Read Aloud": "སྐད་སྒྲས་ཀློག་པ།", "Voice": "སྐད་སྒྲ།", - "Sleep Timer": "གཉིད་ཀྱི་དུས་ཚོད།" + "Sleep Timer": "གཉིད་ཀྱི་དུས་ཚོད།", + "Slower": "འགོར་བ།", + "Faster": "མྱུར་བ།", + "Exit Auto Scroll": "རང་འགུལ་འཁོར་རྒྱུག་མཚམས་འཇོག", + "Auto Scroll": "རང་འགུལ་འཁོར་རྒྱུག", + "End of book": "དཔེ་དེབ་ཀྱི་མཇུག་མཐའ།", + "Toggle Auto Scroll": "རང་འགུལ་འཁོར་རྒྱུག་བརྗེ་བ།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index b8a1565b..f6d60672 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "Vorlese-Player öffnen", "Read Aloud": "Vorlesen", "Voice": "Stimme", - "Sleep Timer": "Einschlaftimer" + "Sleep Timer": "Einschlaftimer", + "Slower": "Langsamer", + "Faster": "Schneller", + "Exit Auto Scroll": "Automatisches Scrollen beenden", + "Auto Scroll": "Automatisches Scrollen", + "End of book": "Ende des Buches", + "Toggle Auto Scroll": "Automatisches Scrollen umschalten" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 08fbd861..1512618c 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "Άνοιγμα προγράμματος αναπαραγωγής εκφώνησης", "Read Aloud": "Εκφώνηση", "Voice": "Φωνή", - "Sleep Timer": "Χρονοδιακόπτης ύπνου" + "Sleep Timer": "Χρονοδιακόπτης ύπνου", + "Slower": "Πιο αργά", + "Faster": "Πιο γρήγορα", + "Exit Auto Scroll": "Έξοδος από την αυτόματη κύλιση", + "Auto Scroll": "Αυτόματη κύλιση", + "End of book": "Τέλος του βιβλίου", + "Toggle Auto Scroll": "Εναλλαγή αυτόματης κύλισης" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 8531432c..e31617bd 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1904,5 +1904,11 @@ "Open Read Aloud player": "Abrir el reproductor de lectura en voz alta", "Read Aloud": "Lectura en voz alta", "Voice": "Voz", - "Sleep Timer": "Temporizador de apagado" + "Sleep Timer": "Temporizador de apagado", + "Slower": "Más lento", + "Faster": "Más rápido", + "Exit Auto Scroll": "Salir del desplazamiento automático", + "Auto Scroll": "Desplazamiento automático", + "End of book": "Fin del libro", + "Toggle Auto Scroll": "Alternar desplazamiento automático" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 9a3a3048..86e9152e 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "باز کردن پخش‌کننده خواندن با صدای بلند", "Read Aloud": "خواندن با صدای بلند", "Voice": "صدا", - "Sleep Timer": "تایمر خواب" + "Sleep Timer": "تایمر خواب", + "Slower": "کندتر", + "Faster": "سریع‌تر", + "Exit Auto Scroll": "خروج از پیمایش خودکار", + "Auto Scroll": "پیمایش خودکار", + "End of book": "پایان کتاب", + "Toggle Auto Scroll": "تغییر پیمایش خودکار" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index a885d6f1..3e42078a 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1904,5 +1904,11 @@ "Open Read Aloud player": "Ouvrir le lecteur de lecture à voix haute", "Read Aloud": "Lecture à voix haute", "Voice": "Voix", - "Sleep Timer": "Minuteur de veille" + "Sleep Timer": "Minuteur de veille", + "Slower": "Plus lent", + "Faster": "Plus rapide", + "Exit Auto Scroll": "Quitter le défilement automatique", + "Auto Scroll": "Défilement automatique", + "End of book": "Fin du livre", + "Toggle Auto Scroll": "Basculer le défilement automatique" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 5b60b380..fcbc8e50 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1904,5 +1904,11 @@ "Open Read Aloud player": "פתיחת נגן ההקראה", "Read Aloud": "הקראה", "Voice": "קול", - "Sleep Timer": "טיימר שינה" + "Sleep Timer": "טיימר שינה", + "Slower": "איטי יותר", + "Faster": "מהיר יותר", + "Exit Auto Scroll": "צא מגלילה אוטומטית", + "Auto Scroll": "גלילה אוטומטית", + "End of book": "סוף הספר", + "Toggle Auto Scroll": "החלף גלילה אוטומטית" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 36e84bdd..fcddb561 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "ज़ोर से पढ़ें प्लेयर खोलें", "Read Aloud": "ज़ोर से पढ़ें", "Voice": "आवाज़", - "Sleep Timer": "स्लीप टाइमर" + "Sleep Timer": "स्लीप टाइमर", + "Slower": "धीमा", + "Faster": "तेज़", + "Exit Auto Scroll": "स्वचालित स्क्रॉल से बाहर निकलें", + "Auto Scroll": "स्वचालित स्क्रॉल", + "End of book": "पुस्तक का अंत", + "Toggle Auto Scroll": "स्वचालित स्क्रॉल टॉगल करें" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index b67b8f14..2b1dbcfa 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "Felolvasó lejátszó megnyitása", "Read Aloud": "Felolvasás", "Voice": "Hang", - "Sleep Timer": "Elalvási időzítő" + "Sleep Timer": "Elalvási időzítő", + "Slower": "Lassabb", + "Faster": "Gyorsabb", + "Exit Auto Scroll": "Kilépés az automatikus görgetésből", + "Auto Scroll": "Automatikus görgetés", + "End of book": "A könyv vége", + "Toggle Auto Scroll": "Automatikus görgetés be/ki" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index eaff2acf..df9cb961 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "Buka pemutar baca nyaring", "Read Aloud": "Baca Nyaring", "Voice": "Suara", - "Sleep Timer": "Timer Tidur" + "Sleep Timer": "Timer Tidur", + "Slower": "Lebih lambat", + "Faster": "Lebih cepat", + "Exit Auto Scroll": "Keluar dari gulir otomatis", + "Auto Scroll": "Gulir Otomatis", + "End of book": "Akhir buku", + "Toggle Auto Scroll": "Alihkan gulir otomatis" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 6b5eadd7..548de84e 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1904,5 +1904,11 @@ "Open Read Aloud player": "Apri il lettore di lettura ad alta voce", "Read Aloud": "Lettura ad alta voce", "Voice": "Voce", - "Sleep Timer": "Timer di spegnimento" + "Sleep Timer": "Timer di spegnimento", + "Slower": "Più lento", + "Faster": "Più veloce", + "Exit Auto Scroll": "Esci dallo scorrimento automatico", + "Auto Scroll": "Scorrimento automatico", + "End of book": "Fine del libro", + "Toggle Auto Scroll": "Attiva/disattiva scorrimento automatico" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 0934bad2..23864cf1 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "読み上げプレーヤーを開く", "Read Aloud": "読み上げ", "Voice": "音声", - "Sleep Timer": "スリープタイマー" + "Sleep Timer": "スリープタイマー", + "Slower": "遅く", + "Faster": "速く", + "Exit Auto Scroll": "自動スクロールを終了", + "Auto Scroll": "自動スクロール", + "End of book": "本の終わり", + "Toggle Auto Scroll": "自動スクロールの切り替え" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 2a550e7d..05de0d11 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "소리 내어 읽기 플레이어 열기", "Read Aloud": "소리 내어 읽기", "Voice": "음성", - "Sleep Timer": "수면 타이머" + "Sleep Timer": "수면 타이머", + "Slower": "느리게", + "Faster": "빠르게", + "Exit Auto Scroll": "자동 스크롤 종료", + "Auto Scroll": "자동 스크롤", + "End of book": "책의 끝", + "Toggle Auto Scroll": "자동 스크롤 전환" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 3dc0e1f6..ccb44ac9 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "Buka pemain baca lantang", "Read Aloud": "Baca Lantang", "Voice": "Suara", - "Sleep Timer": "Pemasa Tidur" + "Sleep Timer": "Pemasa Tidur", + "Slower": "Lebih perlahan", + "Faster": "Lebih cepat", + "Exit Auto Scroll": "Keluar daripada tatal automatik", + "Auto Scroll": "Tatal Automatik", + "End of book": "Penghujung buku", + "Toggle Auto Scroll": "Togol tatal automatik" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index f40239b5..d8543bb4 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "Voorleesspeler openen", "Read Aloud": "Voorlezen", "Voice": "Stem", - "Sleep Timer": "Slaaptimer" + "Sleep Timer": "Slaaptimer", + "Slower": "Langzamer", + "Faster": "Sneller", + "Exit Auto Scroll": "Automatisch scrollen afsluiten", + "Auto Scroll": "Automatisch scrollen", + "End of book": "Einde van het boek", + "Toggle Auto Scroll": "Automatisch scrollen wisselen" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index c264a77c..8051d782 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1939,5 +1939,11 @@ "Open Read Aloud player": "Otwórz odtwarzacz czytania na głos", "Read Aloud": "Czytanie na głos", "Voice": "Głos", - "Sleep Timer": "Minutnik snu" + "Sleep Timer": "Minutnik snu", + "Slower": "Wolniej", + "Faster": "Szybciej", + "Exit Auto Scroll": "Zakończ automatyczne przewijanie", + "Auto Scroll": "Automatyczne przewijanie", + "End of book": "Koniec książki", + "Toggle Auto Scroll": "Przełącz automatyczne przewijanie" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index bf1b63df..fd4ce1f5 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1904,5 +1904,11 @@ "Open Read Aloud player": "Abrir o player de leitura em voz alta", "Read Aloud": "Leitura em voz alta", "Voice": "Voz", - "Sleep Timer": "Timer de sono" + "Sleep Timer": "Timer de sono", + "Slower": "Mais lento", + "Faster": "Mais rápido", + "Exit Auto Scroll": "Sair da rolagem automática", + "Auto Scroll": "Rolagem automática", + "End of book": "Fim do livro", + "Toggle Auto Scroll": "Alternar rolagem automática" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 364b7887..50841ee3 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1904,5 +1904,11 @@ "Open Read Aloud player": "Abrir o leitor de leitura em voz alta", "Read Aloud": "Leitura em voz alta", "Voice": "Voz", - "Sleep Timer": "Temporizador de sono" + "Sleep Timer": "Temporizador de sono", + "Slower": "Mais lento", + "Faster": "Mais rápido", + "Exit Auto Scroll": "Sair da rolagem automática", + "Auto Scroll": "Rolagem automática", + "End of book": "Fim do livro", + "Toggle Auto Scroll": "Alternar rolagem automática" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 23f67bc4..302d7caa 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1904,5 +1904,11 @@ "Open Read Aloud player": "Deschide playerul de citire cu voce tare", "Read Aloud": "Citire cu voce tare", "Voice": "Voce", - "Sleep Timer": "Temporizator de somn" + "Sleep Timer": "Temporizator de somn", + "Slower": "Mai lent", + "Faster": "Mai rapid", + "Exit Auto Scroll": "Ieșire din derularea automată", + "Auto Scroll": "Derulare automată", + "End of book": "Sfârșitul cărții", + "Toggle Auto Scroll": "Comută derularea automată" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index e738b585..4f66c6dc 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1939,5 +1939,11 @@ "Open Read Aloud player": "Открыть плеер чтения вслух", "Read Aloud": "Чтение вслух", "Voice": "Голос", - "Sleep Timer": "Таймер сна" + "Sleep Timer": "Таймер сна", + "Slower": "Медленнее", + "Faster": "Быстрее", + "Exit Auto Scroll": "Выйти из автопрокрутки", + "Auto Scroll": "Автопрокрутка", + "End of book": "Конец книги", + "Toggle Auto Scroll": "Переключить автопрокрутку" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 52072096..ceba5997 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "ශබ්ද නඟා කියවීමේ ධාවකය විවෘත කරන්න", "Read Aloud": "ශබ්ද නඟා කියවීම", "Voice": "හඬ", - "Sleep Timer": "නින්ද කාල ගණකය" + "Sleep Timer": "නින්ද කාල ගණකය", + "Slower": "මන්දගාමී", + "Faster": "වේගවත්", + "Exit Auto Scroll": "ස්වයංක්‍රීය අනුචලනයෙන් පිටවන්න", + "Auto Scroll": "ස්වයංක්‍රීය අනුචලනය", + "End of book": "පොතේ අවසානය", + "Toggle Auto Scroll": "ස්වයංක්‍රීය අනුචලනය මාරු කරන්න" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 3835ab39..f979f96d 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1939,5 +1939,11 @@ "Open Read Aloud player": "Odpri predvajalnik glasnega branja", "Read Aloud": "Glasno branje", "Voice": "Glas", - "Sleep Timer": "Časovnik spanja" + "Sleep Timer": "Časovnik spanja", + "Slower": "Počasneje", + "Faster": "Hitreje", + "Exit Auto Scroll": "Izhod iz samodejnega pomikanja", + "Auto Scroll": "Samodejno pomikanje", + "End of book": "Konec knjige", + "Toggle Auto Scroll": "Preklopi samodejno pomikanje" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index f1261fe5..bd64b97f 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "Öppna uppläsningsspelaren", "Read Aloud": "Uppläsning", "Voice": "Röst", - "Sleep Timer": "Sovtimer" + "Sleep Timer": "Sovtimer", + "Slower": "Långsammare", + "Faster": "Snabbare", + "Exit Auto Scroll": "Avsluta automatisk rullning", + "Auto Scroll": "Automatisk rullning", + "End of book": "Slutet av boken", + "Toggle Auto Scroll": "Växla automatisk rullning" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index ab6f349b..eeac5aca 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "உரக்க வாசித்தல் பிளேயரைத் திற", "Read Aloud": "உரக்க வாசித்தல்", "Voice": "குரல்", - "Sleep Timer": "ஸ்லீப் டைமர்" + "Sleep Timer": "ஸ்லீப் டைமர்", + "Slower": "மெதுவாக", + "Faster": "வேகமாக", + "Exit Auto Scroll": "தானியங்கி உருட்டலில் இருந்து வெளியேறு", + "Auto Scroll": "தானியங்கி உருட்டல்", + "End of book": "புத்தகத்தின் முடிவு", + "Toggle Auto Scroll": "தானியங்கி உருட்டலை மாற்று" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 47fa7d54..baacb50d 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "เปิดเครื่องเล่นอ่านออกเสียง", "Read Aloud": "อ่านออกเสียง", "Voice": "เสียง", - "Sleep Timer": "ตั้งเวลาปิด" + "Sleep Timer": "ตั้งเวลาปิด", + "Slower": "ช้าลง", + "Faster": "เร็วขึ้น", + "Exit Auto Scroll": "ออกจากการเลื่อนอัตโนมัติ", + "Auto Scroll": "เลื่อนอัตโนมัติ", + "End of book": "จุดสิ้นสุดของหนังสือ", + "Toggle Auto Scroll": "สลับการเลื่อนอัตโนมัติ" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 4150f771..b866e6e8 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "Sesli okuma oynatıcısını aç", "Read Aloud": "Sesli Okuma", "Voice": "Ses", - "Sleep Timer": "Uyku Zamanlayıcısı" + "Sleep Timer": "Uyku Zamanlayıcısı", + "Slower": "Daha yavaş", + "Faster": "Daha hızlı", + "Exit Auto Scroll": "Otomatik kaydırmadan çık", + "Auto Scroll": "Otomatik Kaydırma", + "End of book": "Kitabın sonu", + "Toggle Auto Scroll": "Otomatik kaydırmayı aç/kapat" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index c1c4858b..e0a862c3 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1939,5 +1939,11 @@ "Open Read Aloud player": "Відкрити програвач читання вголос", "Read Aloud": "Читання вголос", "Voice": "Голос", - "Sleep Timer": "Таймер сну" + "Sleep Timer": "Таймер сну", + "Slower": "Повільніше", + "Faster": "Швидше", + "Exit Auto Scroll": "Вийти з автоматичної прокрутки", + "Auto Scroll": "Автоматична прокрутка", + "End of book": "Кінець книги", + "Toggle Auto Scroll": "Перемкнути автоматичну прокрутку" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index acb0d884..42f46d49 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1869,5 +1869,11 @@ "Open Read Aloud player": "Ovoz chiqarib oʻqish pleyerini ochish", "Read Aloud": "Ovoz chiqarib oʻqish", "Voice": "Ovoz", - "Sleep Timer": "Uyqu taymeri" + "Sleep Timer": "Uyqu taymeri", + "Slower": "Sekinroq", + "Faster": "Tezroq", + "Exit Auto Scroll": "Avtomatik aylantirishdan chiqish", + "Auto Scroll": "Avtomatik aylantirish", + "End of book": "Kitob oxiri", + "Toggle Auto Scroll": "Avtomatik aylantirishni ochish/yopish" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 9a17e1f2..16f97be6 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "Mở trình phát đọc to", "Read Aloud": "Đọc to", "Voice": "Giọng đọc", - "Sleep Timer": "Hẹn giờ ngủ" + "Sleep Timer": "Hẹn giờ ngủ", + "Slower": "Chậm hơn", + "Faster": "Nhanh hơn", + "Exit Auto Scroll": "Thoát tự động cuộn", + "Auto Scroll": "Tự động cuộn", + "End of book": "Cuối sách", + "Toggle Auto Scroll": "Chuyển chế độ tự động cuộn" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 0fa72c94..870adbab 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "打开朗读播放器", "Read Aloud": "朗读", "Voice": "语音", - "Sleep Timer": "定时关闭" + "Sleep Timer": "定时关闭", + "Slower": "减慢", + "Faster": "加快", + "Exit Auto Scroll": "退出自动滚动", + "Auto Scroll": "自动滚动", + "End of book": "已到达书末", + "Toggle Auto Scroll": "切换自动滚动" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index e8f422f9..74c2ee54 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1834,5 +1834,11 @@ "Open Read Aloud player": "開啟朗讀播放器", "Read Aloud": "朗讀", "Voice": "語音", - "Sleep Timer": "定時關閉" + "Sleep Timer": "定時關閉", + "Slower": "減慢", + "Faster": "加快", + "Exit Auto Scroll": "退出自動捲動", + "Auto Scroll": "自動捲動", + "End of book": "已到達書末", + "Toggle Auto Scroll": "切換自動捲動" } diff --git a/apps/readest-app/src/__tests__/reader/utils/autoscroller.test.ts b/apps/readest-app/src/__tests__/reader/utils/autoscroller.test.ts index 24b05578..e73b7c59 100644 --- a/apps/readest-app/src/__tests__/reader/utils/autoscroller.test.ts +++ b/apps/readest-app/src/__tests__/reader/utils/autoscroller.test.ts @@ -1,9 +1,11 @@ import { describe, test, expect, vi } from 'vitest'; import { Autoscroller, + PacedScroller, AUTOSCROLL_DEAD_ZONE_PX, AUTOSCROLL_SPEED_PER_PX, AUTOSCROLL_MAX_VELOCITY, + PACED_SCROLL_MAX_FRAME_MS, } from '@/app/reader/utils/autoscroller'; // Middle-click autoscroll core (#4951). The controller is pure logic driven by @@ -143,3 +145,169 @@ describe('Autoscroller press/release state machine', () => { expect(hasPendingFrame()).toBe(false); }); }); + +// Auto Scroll reading mode core (#4998): constant-velocity teleprompter +// scrolling, same injected rAF/clock testing approach as the middle-click core. +const createPacedHarness = () => { + let frameCb: FrameRequestCallback | null = null; + let time = 0; + const deltas: number[] = []; + const onStop = vi.fn(); + const scroller = new PacedScroller({ + scrollBy: (delta) => deltas.push(delta), + onStop, + raf: (cb) => { + frameCb = cb; + return 1; + }, + caf: () => { + frameCb = null; + }, + now: () => time, + }); + const step = (ms: number) => { + time += ms; + const cb = frameCb; + frameCb = null; + cb?.(time); + }; + const advanceIdle = (ms: number) => { + time += ms; + }; + const scrolledTotal = () => deltas.reduce((a, b) => a + b, 0); + const hasPendingFrame = () => frameCb !== null; + return { scroller, deltas, onStop, step, advanceIdle, scrolledTotal, hasPendingFrame }; +}; + +describe('PacedScroller', () => { + test('is inactive until started', () => { + const { scroller, hasPendingFrame } = createPacedHarness(); + expect(scroller.active).toBe(false); + expect(hasPendingFrame()).toBe(false); + scroller.start(100); + expect(scroller.active).toBe(true); + expect(scroller.paused).toBe(false); + expect(hasPendingFrame()).toBe(true); + }); + + test('scrolls at the configured velocity', () => { + const { scroller, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + step(100); + expect(scrolledTotal()).toBe(10); + step(100); + expect(scrolledTotal()).toBe(20); + }); + + test('emits whole pixels and carries the fractional remainder', () => { + const { scroller, deltas, step, scrolledTotal } = createPacedHarness(); + // 25 px/s over 10ms frames yields 0.25px per frame; only whole pixels may + // be emitted, with the remainder carried so slow speeds still progress. + scroller.start(25); + for (let i = 0; i < 8; i++) step(10); + expect(scrolledTotal()).toBe(2); + expect(deltas.every((d) => Number.isInteger(d))).toBe(true); + }); + + test('setVelocity takes effect immediately', () => { + const { scroller, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + step(100); + expect(scrolledTotal()).toBe(10); + scroller.setVelocity(200); + step(100); + expect(scrolledTotal()).toBe(30); + }); + + test('pause halts emission and resume continues without a jump', () => { + const { scroller, step, advanceIdle, scrolledTotal, hasPendingFrame } = createPacedHarness(); + scroller.start(100); + step(100); + expect(scrolledTotal()).toBe(10); + scroller.pause(); + expect(scroller.active).toBe(true); + expect(scroller.paused).toBe(true); + expect(hasPendingFrame()).toBe(false); + // Time spent paused must not be scrolled through on resume. + advanceIdle(5000); + scroller.resume(); + expect(scroller.paused).toBe(false); + step(100); + expect(scrolledTotal()).toBe(20); + }); + + test('clamps oversized frame gaps so a background tab does not jump', () => { + const { scroller, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + step(10 * PACED_SCROLL_MAX_FRAME_MS); + expect(scrolledTotal()).toBe((100 * PACED_SCROLL_MAX_FRAME_MS) / 1000); + }); + + test('pause and resume are no-ops while inactive', () => { + const { scroller, onStop, hasPendingFrame } = createPacedHarness(); + scroller.pause(); + scroller.resume(); + expect(scroller.active).toBe(false); + expect(hasPendingFrame()).toBe(false); + expect(onStop).not.toHaveBeenCalled(); + }); + + test('stop is idempotent, cancels the frame loop, and fires onStop once', () => { + const { scroller, onStop, hasPendingFrame } = createPacedHarness(); + scroller.stop(); + expect(onStop).not.toHaveBeenCalled(); + scroller.start(100); + scroller.stop(); + scroller.stop(); + expect(onStop).toHaveBeenCalledTimes(1); + expect(hasPendingFrame()).toBe(false); + }); + + test('stop while paused fires onStop and stays stopped', () => { + const { scroller, onStop, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + scroller.pause(); + scroller.stop(); + expect(scroller.active).toBe(false); + expect(onStop).toHaveBeenCalledTimes(1); + step(100); + expect(scrolledTotal()).toBe(0); + }); + + test('restart after stop scrolls again', () => { + const { scroller, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + step(100); + scroller.stop(); + scroller.start(50); + step(100); + expect(scrolledTotal()).toBe(15); + }); + + test('a scrollBy callback that stops the scroller does not re-arm the frame loop', () => { + let frameCb: FrameRequestCallback | null = null; + let time = 0; + const onStop = vi.fn(); + const scroller: PacedScroller = new PacedScroller({ + // The session auto-stops from inside a tick when the book ends. + scrollBy: () => scroller.stop(), + onStop, + raf: (cb) => { + frameCb = cb; + return 1; + }, + caf: () => { + frameCb = null; + }, + now: () => time, + }); + scroller.start(100); + time += 100; + const cb = frameCb!; + frameCb = null; + cb(time); + expect(onStop).toHaveBeenCalledTimes(1); + expect(scroller.active).toBe(false); + expect(frameCb).toBe(null); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx b/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx index d6543920..08bdd0d8 100644 --- a/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx +++ b/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx @@ -198,6 +198,7 @@ const seedStores = (view: FoliateView, viewSettings: ViewSettings) => { error: null, ribbonVisible: false, ttsEnabled: false, + autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, diff --git a/apps/readest-app/src/__tests__/store/reader-store.test.ts b/apps/readest-app/src/__tests__/store/reader-store.test.ts index e66ecdbb..5c28331b 100644 --- a/apps/readest-app/src/__tests__/store/reader-store.test.ts +++ b/apps/readest-app/src/__tests__/store/reader-store.test.ts @@ -81,6 +81,7 @@ function seedViewState(key: string, overrides: Record = {}) { error: null, ribbonVisible: false, ttsEnabled: false, + autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, diff --git a/apps/readest-app/src/app/reader/components/AutoScrollControl.tsx b/apps/readest-app/src/app/reader/components/AutoScrollControl.tsx new file mode 100644 index 00000000..7b52b58c --- /dev/null +++ b/apps/readest-app/src/app/reader/components/AutoScrollControl.tsx @@ -0,0 +1,184 @@ +'use client'; + +import clsx from 'clsx'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { MdAdd, MdClose, MdPause, MdPlayArrow, MdRemove } from 'react-icons/md'; +import { Insets } from '@/types/misc'; +import { useEnv } from '@/context/EnvContext'; +import { useReaderStore } from '@/store/readerStore'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { useTranslation } from '@/hooks/useTranslation'; +import { MAX_AUTO_SCROLL_SPEED, MIN_AUTO_SCROLL_SPEED } from '@/services/constants'; + +const INITIAL_SHOW_DURATION = 2500; +const HIDE_DELAY = 2000; + +interface AutoScrollControlProps { + bookKey: string; + paused: boolean; + speed: number; + onTogglePause: () => void; + onAdjustSpeed: (dir: 1 | -1) => void; + onStop: () => void; + gridInsets: Insets; +} + +// The Auto Scroll session pill (#4998): speed −/+ around the current +// percentage, pause/resume, and exit. Fades away while scrolling so the mode +// stays immersive; any mouse movement or pausing (tap on the page) brings it +// back. Same floating-pill chassis as ParagraphBar. +const AutoScrollControl: React.FC = ({ + bookKey, + paused, + speed, + onTogglePause, + onAdjustSpeed, + onStop, + gridInsets, +}) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { hoveredBookKey } = useReaderStore(); + const iconSize = useResponsiveSize(18); + + const [isBarVisible, setIsBarVisible] = useState(true); + const hideTimerRef = useRef | null>(null); + const isHoveredRef = useRef(false); + const pausedRef = useRef(paused); + pausedRef.current = paused; + + const clearHideTimer = useCallback(() => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }, []); + + const startHideTimer = useCallback( + (delay: number = HIDE_DELAY) => { + clearHideTimer(); + hideTimerRef.current = setTimeout(() => { + if (!isHoveredRef.current && !pausedRef.current) { + setIsBarVisible(false); + } + }, delay); + }, + [clearHideTimer], + ); + + useEffect(() => { + if (paused) { + clearHideTimer(); + setIsBarVisible(true); + } else { + startHideTimer(INITIAL_SHOW_DURATION); + } + return clearHideTimer; + }, [paused, startHideTimer, clearHideTimer]); + + useEffect(() => { + let lastMoveTime = 0; + const handleMouseMove = () => { + const now = Date.now(); + if (now - lastMoveTime < 100) return; + lastMoveTime = now; + setIsBarVisible(true); + startHideTimer(); + }; + window.addEventListener('mousemove', handleMouseMove, { passive: true }); + return () => window.removeEventListener('mousemove', handleMouseMove); + }, [startHideTimer]); + + const isVisible = isBarVisible && hoveredBookKey !== bookKey; + + const buttonClass = clsx( + 'flex items-center justify-center rounded-full p-1.5', + 'transition-all duration-200 ease-out', + 'not-eink:hover:bg-base-200 active:scale-90', + ); + + return ( +
{ + isHoveredRef.current = true; + clearHideTimer(); + setIsBarVisible(true); + }} + onMouseLeave={() => { + isHoveredRef.current = false; + startHideTimer(); + }} + > +
+ + +
+ {speed}% +
+ + + +
+ + + +
+ + +
+
+ ); +}; + +export default AutoScrollControl; diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index fb749eb1..7a28f772 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -79,8 +79,10 @@ import { eventDispatcher } from '@/utils/event'; import { isFontType } from '@/utils/font'; import { getScrollGapAttr } from '@/utils/webtoon'; import { useMiddleClickAutoscroll } from '../hooks/useMiddleClickAutoscroll'; +import { useAutoScroll } from '../hooks/useAutoScroll'; import { ParagraphControl } from './paragraph'; import AutoscrollIndicator from './AutoscrollIndicator'; +import AutoScrollControl from './AutoScrollControl'; import Spinner from '@/components/Spinner'; import KOSyncConflictResolver from './KOSyncResolver'; import ImageViewer from './ImageViewer'; @@ -517,6 +519,7 @@ const FoliateViewer: React.FC<{ const mouseHandlers = useMouseEvent(bookKey, handlePageFlip); const touchHandlers = useTouchEvent(bookKey); const autoscrollAnchor = useMiddleClickAutoscroll(bookKey, viewRef, containerRef); + const autoScroll = useAutoScroll(bookKey, viewRef); const [selectedImage, setSelectedImage] = useState(null); const [selectedTableHtml, setSelectedTableHtml] = useState(null); @@ -963,6 +966,17 @@ const FoliateViewer: React.FC<{ {...touchHandlers} /> {autoscrollAnchor && } + {autoScroll.active && ( + + )} {((!docLoaded.current && loading) || viewState?.loading) && ( diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx index af22bf01..90ac9e4b 100644 --- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx @@ -123,6 +123,11 @@ const ViewMenu: React.FC = ({ eventDispatcher.dispatch('rsvp-start', { bookKey }); }; + const toggleAutoScroll = () => { + setIsDropdownOpen?.(false); + eventDispatcher.dispatch('autoscroll-toggle', { bookKey }); + }; + const handleShare = () => { setIsDropdownOpen?.(false); if (!bookData?.book) return; @@ -382,6 +387,14 @@ const ViewMenu: React.FC = ({ onClick={toggleScrolledMode} /> + + void; + adjustSpeed: (dir: 1 | -1) => void; + stop: () => void; +} + +// Auto Scroll reading mode (#4998): teleprompter-style continuous scrolling in +// scrolled mode, toggled via the 'autoscroll-toggle' event (View menu, Shift+A). +// A tap on the page pauses/resumes instead of turning the page or toggling the +// bars; manual wheel/drag input simply composes with the paced scrolling since +// every frame is a relative containerPosition step. Escape or leaving scrolled +// mode stops the session; the session state is never persisted. +export const useAutoScroll = ( + bookKey: string, + viewRef: React.RefObject, +): AutoScrollState => { + const _ = useTranslation(); + const { envConfig } = useEnv(); + const { getViewSettings, setAutoScrollEnabled } = useReaderStore(); + const viewSettings = getViewSettings(bookKey); + const [active, setActive] = useState(false); + const [paused, setPaused] = useState(false); + const [speed, setSpeed] = useState(viewSettings?.autoScrollSpeed ?? 100); + + // Undoes the per-session window listeners; set on start. + const sessionCleanupRef = useRef<(() => void) | null>(null); + const stallStartRef = useRef(null); + + const scrollerRef = useRef(null); + if (!scrollerRef.current) { + scrollerRef.current = new PacedScroller({ + scrollBy: (delta) => { + const view = viewRef.current; + const renderer = view?.renderer; + if (!renderer) return; + // Vertical-writing books scroll along the horizontal axis in scrolled + // mode, where reading forward means going more negative (foliate + // paginator negates scrolled-mode offsets for vertical writing). + const sign = renderer.scrollProp === 'scrollLeft' ? -1 : 1; + const before = renderer.containerPosition; + renderer.containerPosition = before + sign * delta; + if (renderer.containerPosition === before) { + // Clamped by the scroll bounds: at the end of the rendered content. + const now = Date.now(); + if (stallStartRef.current === null) { + stallStartRef.current = now; + } else if (now - stallStartRef.current >= AUTO_SCROLL_STALL_MS) { + stallStartRef.current = now; + if (renderer.atEnd) { + scrollerRef.current?.stop(); + eventDispatcher.dispatch('toast', { message: _('End of book'), type: 'info' }); + } else { + // Single-section scroll mode parks at the section end; hop to + // the next section and keep going. + view?.next(); + } + } + } else { + stallStartRef.current = null; + } + }, + onStop: () => { + setActive(false); + setPaused(false); + setAutoScrollEnabled(bookKey, false); + sessionCleanupRef.current?.(); + sessionCleanupRef.current = null; + }, + }); + } + + const togglePause = () => { + const scroller = scrollerRef.current!; + if (!scroller.active) return; + if (scroller.paused) scroller.resume(); + else scroller.pause(); + setPaused(scroller.paused); + }; + + const adjustSpeed = (dir: 1 | -1) => { + const current = getViewSettings(bookKey)?.autoScrollSpeed ?? speed; + const next = Math.min( + MAX_AUTO_SCROLL_SPEED, + Math.max(MIN_AUTO_SCROLL_SPEED, current + dir * AUTO_SCROLL_SPEED_STEP), + ); + setSpeed(next); + scrollerRef.current!.setVelocity((AUTO_SCROLL_BASE_PX_PER_SEC * next) / 100); + saveViewSettings(envConfig, bookKey, 'autoScrollSpeed', next, false, false); + }; + + const startSession = () => { + const scroller = scrollerRef.current!; + const renderer = viewRef.current?.renderer; + if (!renderer?.scrolled) return; + const speedNow = getViewSettings(bookKey)?.autoScrollSpeed ?? 100; + setSpeed(speedNow); + stallStartRef.current = null; + scroller.start((AUTO_SCROLL_BASE_PX_PER_SEC * speedNow) / 100); + setActive(true); + setPaused(false); + setAutoScrollEnabled(bookKey, true); + + const onKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') scroller.stop(); + }; + window.addEventListener('keydown', onKeydown, true); + sessionCleanupRef.current = () => { + window.removeEventListener('keydown', onKeydown, true); + }; + }; + + useEffect(() => { + const scroller = scrollerRef.current!; + const onToggle = (event: CustomEvent) => { + if (event.detail?.bookKey !== bookKey) return; + if (scroller.active) scroller.stop(); + else startSession(); + }; + // Escape typed inside the book iframe arrives as a forwarded message. + const onMessage = (msg: MessageEvent) => { + if (msg.data?.type === 'iframe-keydown' && msg.data.key === 'Escape') scroller.stop(); + }; + // A tap on the page pauses/resumes the session; consuming the click keeps + // it from also turning the page or toggling the header/footer bars. + const onSingleClick = () => { + if (!scroller.active) return false; + togglePause(); + return true; + }; + eventDispatcher.on('autoscroll-toggle', onToggle); + eventDispatcher.onSync('iframe-single-click', onSingleClick); + window.addEventListener('message', onMessage); + return () => { + eventDispatcher.off('autoscroll-toggle', onToggle); + eventDispatcher.offSync('iframe-single-click', onSingleClick); + window.removeEventListener('message', onMessage); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey]); + + // Auto Scroll only exists in scrolled mode; leaving it ends the session. + const scrolled = !!viewSettings?.scrolled; + useEffect(() => { + if (!scrolled) scrollerRef.current?.stop(); + }, [scrolled]); + + useEffect(() => { + return () => scrollerRef.current?.stop(); + }, []); + + return { + active, + paused, + speed, + togglePause, + adjustSpeed, + stop: () => scrollerRef.current?.stop(), + }; +}; diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index 746b23a0..eb105ce4 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -360,6 +360,14 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = eventDispatcher.dispatch('rsvp-start', { bookKey: sideBarBookKey }); }; + const toggleAutoScroll = () => { + if (!sideBarBookKey) return; + // Auto Scroll only exists in scrolled mode (#4998); the View menu item is + // disabled outside it, so the shortcut silently no-ops there too. + if (!getViewSettings(sideBarBookKey)?.scrolled) return; + eventDispatcher.dispatch('autoscroll-toggle', { bookKey: sideBarBookKey }); + }; + const handlePinchZoom = (event: CustomEvent) => { const zoomLevel = event.detail?.zoomLevel; if (zoomLevel != null) { @@ -393,6 +401,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = onToggleBookmark: toggleBookmark, onToggleParagraphMode: toggleParagraphMode, onStartRSVP: startRSVP, + onToggleAutoScroll: toggleAutoScroll, onToggleToolbar: toggleToolbar, onOpenFontLayoutSettings: () => setSettingsDialogOpen(true), onShowSearchBar: showSearchBar, diff --git a/apps/readest-app/src/app/reader/utils/autoscroller.ts b/apps/readest-app/src/app/reader/utils/autoscroller.ts index f67770c8..0d522374 100644 --- a/apps/readest-app/src/app/reader/utils/autoscroller.ts +++ b/apps/readest-app/src/app/reader/utils/autoscroller.ts @@ -108,3 +108,94 @@ export class Autoscroller { this.#requestFrame(); } } + +// Frame gaps above this are treated as this long: rAF stops in background tabs, +// and scrolling through the whole gap on the resume frame would jump the text. +export const PACED_SCROLL_MAX_FRAME_MS = 100; + +// Auto Scroll reading mode core (#4998): teleprompter-style scrolling at a +// constant caller-set velocity, with pause/resume. Emits whole-pixel forward +// deltas with fractional carry like Autoscroller; the caller owns the scroll +// direction sign. Pure logic with an injectable rAF/clock for tests. +export class PacedScroller { + #opts: AutoscrollerOptions; + #active = false; + #paused = false; + #velocity = 0; + #lastTime = 0; + #residual = 0; + #frameId: number | null = null; + + constructor(opts: AutoscrollerOptions) { + this.#opts = opts; + } + + get active() { + return this.#active; + } + + get paused() { + return this.#paused; + } + + start(velocity: number) { + this.stop(); + this.#active = true; + this.#paused = false; + this.#velocity = velocity; + this.#residual = 0; + this.#resetClockAndRequestFrame(); + } + + setVelocity(velocity: number) { + this.#velocity = velocity; + } + + pause() { + if (!this.#active || this.#paused) return; + this.#paused = true; + this.#cancelFrame(); + } + + resume() { + if (!this.#active || !this.#paused) return; + this.#paused = false; + this.#resetClockAndRequestFrame(); + } + + stop() { + if (!this.#active) return; + this.#active = false; + this.#paused = false; + this.#cancelFrame(); + this.#opts.onStop?.(); + } + + #cancelFrame() { + if (this.#frameId !== null) { + (this.#opts.caf ?? cancelAnimationFrame)(this.#frameId); + this.#frameId = null; + } + } + + #resetClockAndRequestFrame() { + this.#lastTime = (this.#opts.now ?? performance.now.bind(performance))(); + this.#frameId = (this.#opts.raf ?? requestAnimationFrame)((time) => this.#tick(time)); + } + + #tick(time: number) { + this.#frameId = null; + if (!this.#active || this.#paused) return; + const dt = Math.min(Math.max(0, time - this.#lastTime), PACED_SCROLL_MAX_FRAME_MS); + this.#lastTime = time; + this.#residual += this.#velocity * (dt / 1000); + const whole = Math.trunc(this.#residual); + if (whole !== 0) { + this.#residual -= whole; + this.#opts.scrollBy(whole); + // scrollBy may stop the session (e.g. the book ended); don't re-arm then. + if (!this.#active || this.#paused) return; + } + this.#frameId = (this.#opts.raf ?? requestAnimationFrame)((t) => this.#tick(t)); + } +} diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index bce57498..17557c50 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -83,6 +83,11 @@ const DEFAULT_SHORTCUTS = { description: _('Toggle Paragraph Mode'), section: 'General', }, + onToggleAutoScroll: { + keys: ['shift+a'], + description: _('Toggle Auto Scroll'), + section: 'General', + }, onStartRSVP: { keys: ['shift+v'], description: _('Speed Reading Mode'), diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 06bf7f3b..fcba344b 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -297,6 +297,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = { scrollingOverlap: 0, allowScript: false, hideScrollbar: false, + autoScrollSpeed: 100, }; export const DEFAULT_BOOK_LANGUAGE: BookLanguage = { @@ -882,6 +883,12 @@ export const MAX_CONTRAST = 300; export const MIN_CONTRAST = 50; export const CONTRAST_STEP = 10; +// Auto Scroll (#4998): speed is stored as a percentage of the base velocity. +export const AUTO_SCROLL_BASE_PX_PER_SEC = 20; +export const MAX_AUTO_SCROLL_SPEED = 500; +export const MIN_AUTO_SCROLL_SPEED = 25; +export const AUTO_SCROLL_SPEED_STEP = 25; + export const SHOW_UNREAD_STATUS_BADGE = false; export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = { diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index 43492613..e78c71a7 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -42,6 +42,9 @@ interface ViewState { `getBookProgress(key)` for one-shot reads. */ ribbonVisible: boolean; ttsEnabled: boolean; + /* True while an Auto Scroll session (#4998) is engaged for this view; + session-only, never persisted. Drives the View menu checkmark. */ + autoScrollEnabled: boolean; syncing: boolean; gridInsets: Insets | null; /* True while the reader is showing a position requested by an external @@ -65,6 +68,7 @@ interface ReaderStore { setHoveredBookKey: (key: string | null) => void; setBookmarkRibbonVisibility: (key: string, visible: boolean) => void; setTTSEnabled: (key: string, enabled: boolean) => void; + setAutoScrollEnabled: (key: string, enabled: boolean) => void; setIsLoading: (key: string, loading: boolean) => void; setIsSyncing: (key: string, syncing: boolean) => void; setProgress: ( @@ -157,6 +161,7 @@ export const useReaderStore = create((set, get) => ({ error: null, ribbonVisible: false, ttsEnabled: false, + autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, @@ -296,6 +301,7 @@ export const useReaderStore = create((set, get) => ({ error: null, ribbonVisible: false, ttsEnabled: false, + autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, @@ -319,6 +325,7 @@ export const useReaderStore = create((set, get) => ({ error: 'Failed to load book.', ribbonVisible: false, ttsEnabled: false, + autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, @@ -466,6 +473,17 @@ export const useReaderStore = create((set, get) => ({ }, })), + setAutoScrollEnabled: (key: string, enabled: boolean) => + set((state) => ({ + viewStates: { + ...state.viewStates, + [key]: { + ...state.viewStates[key]!, + autoScrollEnabled: enabled, + }, + }, + })), + setIsLoading: (key: string, loading: boolean) => set((state) => ({ viewStates: { diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 32374b15..7748ad1d 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -209,6 +209,8 @@ export interface BookLayout { scrollingOverlap: number; allowScript: boolean; hideScrollbar: boolean; + /* Auto Scroll (#4998) speed as a percentage; 100 = AUTO_SCROLL_BASE_PX_PER_SEC. */ + autoScrollSpeed: number; } export interface BookStyle {