From f8ad47a418d608aa4d36bb7ab0533b0f3d0119bb Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 8 Jul 2026 00:45:02 +0900 Subject: [PATCH] feat(reader): Auto Scroll reading mode for scrolled flow (#4998) (#4999) Teleprompter-style continuous scrolling toggled from the View menu (Shift+A), available only in scrolled mode. A PacedScroller drives whole-pixel forward steps at a constant, user-adjustable velocity; the speed (25-500 percent, persisted as autoScrollSpeed) is tuned from a floating control pill that also offers pause/resume and exit, and fades away while scrolling to keep the mode immersive. Tapping the page pauses and resumes instead of turning pages or toggling the bars; manual wheel or drag input simply composes with the paced scrolling. Escape or leaving scrolled mode ends the session. When forward progress stalls the session hops to the next section (single-section scroll mode) or stops with a toast at the end of the book. Vertical-writing books scroll along the horizontal axis with the sign convention foliate uses for scrolled offsets. Co-authored-by: Claude Opus 4.8 (1M context) --- .../public/locales/ar/translation.json | 8 +- .../public/locales/bn/translation.json | 8 +- .../public/locales/bo/translation.json | 8 +- .../public/locales/de/translation.json | 8 +- .../public/locales/el/translation.json | 8 +- .../public/locales/es/translation.json | 8 +- .../public/locales/fa/translation.json | 8 +- .../public/locales/fr/translation.json | 8 +- .../public/locales/he/translation.json | 8 +- .../public/locales/hi/translation.json | 8 +- .../public/locales/hu/translation.json | 8 +- .../public/locales/id/translation.json | 8 +- .../public/locales/it/translation.json | 8 +- .../public/locales/ja/translation.json | 8 +- .../public/locales/ko/translation.json | 8 +- .../public/locales/ms/translation.json | 8 +- .../public/locales/nl/translation.json | 8 +- .../public/locales/pl/translation.json | 8 +- .../public/locales/pt-BR/translation.json | 8 +- .../public/locales/pt/translation.json | 8 +- .../public/locales/ro/translation.json | 8 +- .../public/locales/ru/translation.json | 8 +- .../public/locales/si/translation.json | 8 +- .../public/locales/sl/translation.json | 8 +- .../public/locales/sv/translation.json | 8 +- .../public/locales/ta/translation.json | 8 +- .../public/locales/th/translation.json | 8 +- .../public/locales/tr/translation.json | 8 +- .../public/locales/uk/translation.json | 8 +- .../public/locales/uz/translation.json | 8 +- .../public/locales/vi/translation.json | 8 +- .../public/locales/zh-CN/translation.json | 8 +- .../public/locales/zh-TW/translation.json | 8 +- .../reader/utils/autoscroller.test.ts | 168 ++++++++++++++++ .../tts-auto-advance.browser.test.tsx | 1 + .../src/__tests__/store/reader-store.test.ts | 1 + .../reader/components/AutoScrollControl.tsx | 184 ++++++++++++++++++ .../app/reader/components/FoliateViewer.tsx | 14 ++ .../src/app/reader/components/ViewMenu.tsx | 13 ++ .../src/app/reader/hooks/useAutoScroll.ts | 182 +++++++++++++++++ .../src/app/reader/hooks/useBookShortcuts.ts | 9 + .../src/app/reader/utils/autoscroller.ts | 91 +++++++++ apps/readest-app/src/helpers/shortcuts.ts | 5 + apps/readest-app/src/services/constants.ts | 7 + apps/readest-app/src/store/readerStore.ts | 18 ++ apps/readest-app/src/types/book.ts | 2 + 46 files changed, 926 insertions(+), 33 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/AutoScrollControl.tsx create mode 100644 apps/readest-app/src/app/reader/hooks/useAutoScroll.ts 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 {