From 17de9357ddc9a25c28e95cb25d80210ee0120cf2 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 7 Jul 2026 23:37:59 +0900 Subject: [PATCH] feat(reader): redesign the TTS control as a mini player with an expandable player sheet (#4996) * feat(reader): add formatCountdown helper for TTS timer chips Co-Authored-By: Claude Fable 5 * feat(reader): extract shared TTS playback info hook Co-Authored-By: Claude Fable 5 * feat(reader): add TTS scrubber with buffer-ahead fill Co-Authored-By: Claude Fable 5 * feat(reader): add TTS speed preset chips Co-Authored-By: Claude Fable 5 * feat(reader): add persistent TTS mini player Co-Authored-By: Claude Fable 5 * feat(reader): add full TTS player sheet with voice and timer sub-views Co-Authored-By: Claude Fable 5 * test(reader): cover player sheet view reset on reopen Co-Authored-By: Claude Fable 5 * feat(reader): replace TTS icon and popup with mini player and player sheet Co-Authored-By: Claude Fable 5 * feat(reader): reserve mini player clearance and retire showTTSBar setting Co-Authored-By: Claude Fable 5 * chore: retire shipped TTS follow-ups from TODOS Co-Authored-By: Claude Fable 5 * chore(i18n): translate the TTS player strings Co-Authored-By: Claude Fable 5 * fix(reader): pin mini player transport LTR and unmount the closed player sheet Co-Authored-By: Claude Fable 5 * feat(reader): collapse sheet speed, voice, and timer controls into one row Co-Authored-By: Claude Fable 5 * feat(tts): stabilize timeline estimates with cumulative voice calibration Replace the per-sentence EMA with the cumulative ratio of all measured chars to all measured seconds per voice, so the estimated remainder converges instead of re-pricing on every quirky sentence. Legacy stored calibrations migrate as a small prior. Co-Authored-By: Claude Fable 5 * fix(reader): hide the mini player while the player sheet is open Co-Authored-By: Claude Fable 5 * fix(tts): clear stale highlights across sections and stop sentence flash in word mode Entering a section now scrubs the TTS highlight from every live view, not just the primary, so the outgoing section's last word no longer stays lit in the preloaded neighbor. reapplyCurrentHighlight no longer redraws the whole sentence during word-mode playback while awaiting the first word boundary. Co-Authored-By: Claude Fable 5 * style(reader): move the mini player progress line to the bottom edge Co-Authored-By: Claude Fable 5 * fix(reader): make the TTS progress bar and scrubber legible in eink mode Grey tints wash out on e-ink: the mini player track gets a 1px hairline with a solid base-content fill (buffer fill hidden), and the sheet scrubber gets a crisp 1px border marking the track extent. Co-Authored-By: Claude Fable 5 * style(reader): drop the player sheet header label on the main view Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/readest-app/TODOS.md | 11 - .../public/locales/ar/translation.json | 11 +- .../public/locales/bn/translation.json | 11 +- .../public/locales/bo/translation.json | 11 +- .../public/locales/de/translation.json | 11 +- .../public/locales/el/translation.json | 11 +- .../public/locales/es/translation.json | 11 +- .../public/locales/fa/translation.json | 11 +- .../public/locales/fr/translation.json | 11 +- .../public/locales/he/translation.json | 11 +- .../public/locales/hi/translation.json | 11 +- .../public/locales/hu/translation.json | 11 +- .../public/locales/id/translation.json | 11 +- .../public/locales/it/translation.json | 11 +- .../public/locales/ja/translation.json | 11 +- .../public/locales/ko/translation.json | 11 +- .../public/locales/ms/translation.json | 11 +- .../public/locales/nl/translation.json | 11 +- .../public/locales/pl/translation.json | 11 +- .../public/locales/pt-BR/translation.json | 11 +- .../public/locales/pt/translation.json | 11 +- .../public/locales/ro/translation.json | 11 +- .../public/locales/ru/translation.json | 11 +- .../public/locales/si/translation.json | 11 +- .../public/locales/sl/translation.json | 11 +- .../public/locales/sv/translation.json | 11 +- .../public/locales/ta/translation.json | 11 +- .../public/locales/th/translation.json | 11 +- .../public/locales/tr/translation.json | 11 +- .../public/locales/uk/translation.json | 11 +- .../public/locales/uz/translation.json | 11 +- .../public/locales/vi/translation.json | 11 +- .../public/locales/zh-CN/translation.json | 11 +- .../public/locales/zh-TW/translation.json | 11 +- .../components/tts/SpeedChips.test.tsx | 37 ++ .../components/tts/TTSControl.test.tsx | 100 +++ .../components/tts/TTSMiniPlayer.test.tsx | 128 ++++ .../components/tts/TTSPlayerSheet.test.tsx | 196 ++++++ .../components/tts/TTSScrubber.test.tsx | 77 +++ .../components/tts/usePlaybackInfo.test.tsx | 141 +++++ .../__tests__/hooks/useTTSControl.test.tsx | 1 - .../src/__tests__/services/constants.test.ts | 1 - .../__tests__/services/tts-controller.test.ts | 65 ++ .../__tests__/services/tts-duration.test.ts | 35 ++ .../src/__tests__/utils/time-format.test.ts | 15 +- .../app/reader/components/FoliateViewer.tsx | 15 +- .../app/reader/components/tts/SpeedChips.tsx | 51 ++ .../src/app/reader/components/tts/TTSBar.tsx | 99 --- .../app/reader/components/tts/TTSControl.tsx | 228 ++----- .../src/app/reader/components/tts/TTSIcon.tsx | 66 -- .../reader/components/tts/TTSMiniPlayer.tsx | 198 ++++++ .../app/reader/components/tts/TTSPanel.tsx | 571 ------------------ .../reader/components/tts/TTSPlayerSheet.tsx | 394 ++++++++++++ .../app/reader/components/tts/TTSScrubber.tsx | 100 +++ .../components/tts/useCountdownLabel.ts | 19 + .../reader/components/tts/usePlaybackInfo.ts | 102 ++++ .../src/app/reader/hooks/useTTSControl.ts | 13 - apps/readest-app/src/services/constants.ts | 1 - .../src/services/tts/TTSController.ts | 32 +- .../src/services/tts/ttsDuration.ts | 63 +- apps/readest-app/src/styles/globals.css | 36 ++ apps/readest-app/src/types/book.ts | 1 - apps/readest-app/src/utils/time.ts | 9 + 63 files changed, 2067 insertions(+), 1101 deletions(-) create mode 100644 apps/readest-app/src/__tests__/components/tts/SpeedChips.test.tsx create mode 100644 apps/readest-app/src/__tests__/components/tts/TTSControl.test.tsx create mode 100644 apps/readest-app/src/__tests__/components/tts/TTSMiniPlayer.test.tsx create mode 100644 apps/readest-app/src/__tests__/components/tts/TTSPlayerSheet.test.tsx create mode 100644 apps/readest-app/src/__tests__/components/tts/TTSScrubber.test.tsx create mode 100644 apps/readest-app/src/__tests__/components/tts/usePlaybackInfo.test.tsx create mode 100644 apps/readest-app/src/app/reader/components/tts/SpeedChips.tsx delete mode 100644 apps/readest-app/src/app/reader/components/tts/TTSBar.tsx delete mode 100644 apps/readest-app/src/app/reader/components/tts/TTSIcon.tsx create mode 100644 apps/readest-app/src/app/reader/components/tts/TTSMiniPlayer.tsx delete mode 100644 apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx create mode 100644 apps/readest-app/src/app/reader/components/tts/TTSPlayerSheet.tsx create mode 100644 apps/readest-app/src/app/reader/components/tts/TTSScrubber.tsx create mode 100644 apps/readest-app/src/app/reader/components/tts/useCountdownLabel.ts create mode 100644 apps/readest-app/src/app/reader/components/tts/usePlaybackInfo.ts diff --git a/apps/readest-app/TODOS.md b/apps/readest-app/TODOS.md index 51137f48..581cbb93 100644 --- a/apps/readest-app/TODOS.md +++ b/apps/readest-app/TODOS.md @@ -33,24 +33,13 @@ Each was explicitly deferred, not forgotten — see the Decision Audit Trail in - [ ] Cross-section gapless playback: preload and schedule the next section's first sentence so chapter boundaries are as seamless as sentence boundaries. (M) -- [ ] Buffer-ahead indicator in the TTS scrubber (show the prefetched region, - YouTube-style). (S) - [ ] Lock-screen ±10s seek offsets in addition to prev/next sentence. (S) - [ ] Persist measured sentence durations per book so a reopened chapter starts with an exact timeline instead of estimates. (S) - [ ] Worker offload for decode + WSOLA if device profiling shows main-thread jank on low-end Android. (S) -- [ ] Sticky TTSBar scrubber (panel + lock screen only in the first release; - recorded as deliberate in decision #24). (S) - [ ] Provider-agnostic voice source hedge: local neural TTS (e.g. Piper/Kokoro WASM) plugging into WebAudioPlayer/SectionTimeline — the engine is designed for this; see "Strategic framing" in the plan. (L) - [ ] Background chapter prefetch (convert timeline estimates to exact durations ahead of playback). (M) -- [ ] Background TTS: decouple session ownership from the reader view via an - app-level TTSSessionManager so closing the book keeps TTS playing - (headless text supply via section.createDocument(), CFI re-anchoring for - highlights on reattach, library now-playing pill). Decided matrix: close - book = keep playing; reopen same book = seamless reattach (adopt session, - redispatchPosition, lazy doc swap at next section); open a DIFFERENT - book = TTS stops; explicit stop / sleep timer = stops. (M) diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index d2cc1e46..55851500 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -30,7 +30,6 @@ "Edit": "تحرير", "Excerpts": "مقتطفات", "Failed to import book(s): {{filenames}}": "فشل استيراد الكتاب/الكتب: {{filenames}}", - "Fast": "سريع", "Font": "الخط", "Font & Layout": "الخط والتخطيط", "Font Face": "نوع الخط", @@ -88,7 +87,6 @@ "Sign In": "تسجيل الدخول", "Sign Out": "تسجيل الخروج", "Sky": "سماوي", - "Slow": "بطيء", "Solarized": "سولاريزد", "Speak": "تحدث", "Subjects": "المواضيع", @@ -920,7 +918,6 @@ "Export": "تصدير", "Set Timeout": "تعيين المهلة", "Select Voice": "اختر الصوت", - "Toggle Sticky Bottom TTS Bar": "تبديل شريط TTS الثابت", "Display what I'm reading on Discord": "عرض ما أقرأه على Discord", "Show on Discord": "عرض على Discord", "Instant {{action}}": "{{action}} فوري", @@ -2006,5 +2003,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "عند اختيار {{provider}}، تتم مزامنة الكتب والتقدم والتعليقات التوضيحية إلى Drive الخاص بك فقط.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "عند اختيار {{provider}}، تتم مزامنة الكتب والتقدم والتعليقات التوضيحية إلى حاويتك فقط.", "Premium": "بريميوم", - "S3 Storage": "تخزين S3" + "S3 Storage": "تخزين S3", + "Speed": "السرعة", + "{{time}} left in chapter": "متبقي {{time}} في الفصل", + "Open Read Aloud player": "فتح مشغل القراءة بصوت عالٍ", + "Read Aloud": "القراءة بصوت عالٍ", + "Voice": "الصوت", + "Sleep Timer": "مؤقت النوم" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 7b4843ec..e6403604 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -320,8 +320,6 @@ "{{value}} hour": "{{value}} ঘন্টা", "{{value}} hours": "{{value}} ঘন্টা", "Voices for {{lang}}": "{{lang}} এর জন্য কণ্ঠস্বর", - "Slow": "ধীর", - "Fast": "দ্রুত", "{{engine}}: {{count}} voices_one": "{{engine}}: ১টি কণ্ঠস্বর", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}টি কণ্ঠস্বর", "Sign in to Sync": "সিঙ্কের জন্য সাইন ইন", @@ -876,7 +874,6 @@ "Export": "রপ্তানি", "Set Timeout": "টাইমআউট সেট করুন", "Select Voice": "ভয়েস নির্বাচন করুন", - "Toggle Sticky Bottom TTS Bar": "স্থির TTS বার টগল করুন", "Display what I'm reading on Discord": "Discord-এ পড়ছি যা দেখান", "Show on Discord": "Discord-এ দেখান", "Instant {{action}}": "তাৎক্ষণিক {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} নির্বাচিত থাকাকালীন বই, অগ্রগতি ও টীকা শুধুমাত্র আপনার Drive-এ সিঙ্ক হয়।", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} নির্বাচিত থাকাকালীন বই, অগ্রগতি ও টীকা শুধুমাত্র আপনার বাকেটে সিঙ্ক হয়।", "Premium": "প্রিমিয়াম", - "S3 Storage": "S3 স্টোরেজ" + "S3 Storage": "S3 স্টোরেজ", + "Speed": "গতি", + "{{time}} left in chapter": "অধ্যায়ে {{time}} বাকি", + "Open Read Aloud player": "জোরে পড়ার প্লেয়ার খুলুন", + "Read Aloud": "জোরে পড়ুন", + "Voice": "কণ্ঠস্বর", + "Sleep Timer": "স্লিপ টাইমার" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index b46e1d10..d05f7569 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -30,7 +30,6 @@ "Edit": "རྩོམ་སྒྲིག", "Excerpts": "དྲངས་བཏུས།", "Failed to import book(s): {{filenames}}": "དཔེ་དེབ་ནང་འདྲེན་བྱས་མ་ཐུབ། {{filenames}}", - "Fast": "མགྱོགས་པོ།", "Font": "ཡིག་གཟུགས།", "Font & Layout": "ཡིག་གཟུགས་དང་བཀོད་སྒྲིག", "Font Face": "ཡིག་གཟུགས་ཀྱི་རྣམ་པ།", @@ -89,7 +88,6 @@ "Sign In": "ནང་བསྐྱོད།", "Sign Out": "ཕྱིར་བུད།", "Sky": "མཁའ་དཀར།", - "Slow": "དལ་བ།", "Solarized": "ཉི་འོད།", "Speak": "སྒྲ་ཀློག", "Subjects": "བརྗོད་བྱ།", @@ -865,7 +863,6 @@ "Export": "ཕྱིར་འདོན།", "Set Timeout": "དུས་ཚོད་སྒྲིག་པ།", "Select Voice": "སྐད་གདངས་འདེམས།", - "Toggle Sticky Bottom TTS Bar": "TTS སྡོམ་ཐིག་བརྗེ་བ།", "Display what I'm reading on Discord": "Discord ཐོག་ཀློག་བཞིན་པའི་དཔེ་ཆ་སྟོན།", "Show on Discord": "Discord ཐོག་སྟོན།", "Instant {{action}}": "འཕྲལ་མར་{{action}}", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} བདམས་ཡོད་སྐབས། དཔེ་ཆ་དང་བགྲོད་རིམ། མཆན་འགྲེལ་བཅས་ཁྱེད་ཀྱི་ Drive ནང་ཁོ་ནར་མཉམ་བསྒྲིག་བྱེད།", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} བདམས་ཡོད་སྐབས། དཔེ་ཆ་དང་བགྲོད་རིམ། མཆན་འགྲེལ་བཅས་ཁྱེད་ཀྱི་ bucket ནང་ཁོ་ནར་མཉམ་བསྒྲིག་བྱེད།", "Premium": "སྤུས་ལྡན།", - "S3 Storage": "S3 གསོག་མཛོད།" + "S3 Storage": "S3 གསོག་མཛོད།", + "Speed": "མྱུར་ཚད།", + "{{time}} left in chapter": "ལེའུ་འདིར་ {{time}} ལྷག་ཡོད།", + "Open Read Aloud player": "སྐད་སྒྲས་ཀློག་པའི་གཏོང་ཆས་ཁ་ཕྱེ།", + "Read Aloud": "སྐད་སྒྲས་ཀློག་པ།", + "Voice": "སྐད་སྒྲ།", + "Sleep Timer": "གཉིད་ཀྱི་དུས་ཚོད།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 12300149..b8a1565b 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -30,7 +30,6 @@ "Edit": "Bearbeiten", "Excerpts": "Auszüge", "Failed to import book(s): {{filenames}}": "Fehler beim Importieren von Buch/Büchern: {{filenames}}", - "Fast": "Schnell", "Font": "Schriftart", "Font & Layout": "Schrift & Layout", "Font Face": "Schriftschnitt", @@ -88,7 +87,6 @@ "Sign In": "Anmelden", "Sign Out": "Abmelden", "Sky": "Himmelblau", - "Slow": "Langsam", "Solarized": "Solarisiert", "Speak": "Sprechen", "Subjects": "Themen", @@ -876,7 +874,6 @@ "Export": "Exportieren", "Set Timeout": "Zeitlimit festlegen", "Select Voice": "Stimme auswählen", - "Toggle Sticky Bottom TTS Bar": "Fixierte TTS-Leiste umschalten", "Display what I'm reading on Discord": "Zeige was ich auf Discord lese", "Show on Discord": "Auf Discord zeigen", "Instant {{action}}": "Sofort {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Solange {{provider}} ausgewählt ist, werden Bücher, Fortschritt und Anmerkungen nur mit Ihrem Drive synchronisiert.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Solange {{provider}} ausgewählt ist, werden Bücher, Fortschritt und Anmerkungen nur mit Ihrem Bucket synchronisiert.", "Premium": "Premium", - "S3 Storage": "S3-Speicher" + "S3 Storage": "S3-Speicher", + "Speed": "Geschwindigkeit", + "{{time}} left in chapter": "{{time}} verbleibend im Kapitel", + "Open Read Aloud player": "Vorlese-Player öffnen", + "Read Aloud": "Vorlesen", + "Voice": "Stimme", + "Sleep Timer": "Einschlaftimer" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 69c26ee1..08fbd861 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -30,7 +30,6 @@ "Edit": "Επεξεργασία", "Excerpts": "Αποσπάσματα", "Failed to import book(s): {{filenames}}": "Αποτυχία εισαγωγής βιβλίου(ων): {{filenames}}", - "Fast": "Γρήγορα", "Font": "Γραμματοσειρά", "Font & Layout": "Γραμματοσειρά & Διάταξη", "Font Face": "Τύπος γραμματοσειράς", @@ -89,7 +88,6 @@ "Sign In": "Σύνδεση", "Sign Out": "Αποσύνδεση", "Sky": "Ουρανός", - "Slow": "Αργά", "Solarized": "Solarized", "Speak": "Ομιλία", "Subjects": "Θέματα", @@ -876,7 +874,6 @@ "Export": "Εξαγωγή", "Set Timeout": "Ορισμός χρονικού ορίου", "Select Voice": "Επιλογή φωνής", - "Toggle Sticky Bottom TTS Bar": "Εναλλαγή καρφιτσωμένης μπάρας TTS", "Display what I'm reading on Discord": "Εμφάνιση του βιβλίου που διαβάζω στο Discord", "Show on Discord": "Εμφάνιση στο Discord", "Instant {{action}}": "Άμεση {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Όσο είναι επιλεγμένο το {{provider}}, τα βιβλία, η πρόοδος και οι σημειώσεις συγχρονίζονται μόνο με το Drive σας.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Όσο είναι επιλεγμένο το {{provider}}, τα βιβλία, η πρόοδος και οι σημειώσεις συγχρονίζονται μόνο με τον κάδο σας.", "Premium": "Premium", - "S3 Storage": "Αποθήκευση S3" + "S3 Storage": "Αποθήκευση S3", + "Speed": "Ταχύτητα", + "{{time}} left in chapter": "{{time}} απομένει στο κεφάλαιο", + "Open Read Aloud player": "Άνοιγμα προγράμματος αναπαραγωγής εκφώνησης", + "Read Aloud": "Εκφώνηση", + "Voice": "Φωνή", + "Sleep Timer": "Χρονοδιακόπτης ύπνου" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 424504f3..8531432c 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -30,7 +30,6 @@ "Edit": "Editar", "Excerpts": "Extractos", "Failed to import book(s): {{filenames}}": "Error al importar libro(s): {{filenames}}", - "Fast": "Rápido", "Font": "Fuente", "Font & Layout": "Fuente y diseño", "Font Face": "Tipo de fuente", @@ -112,7 +111,6 @@ "Sign In": "Iniciar sesión", "Sign Out": "Cerrar sesión", "Sky": "Cielo", - "Slow": "Lento", "Solarized": "Solarizado", "Speak": "Leer", "Subjects": "Temas", @@ -887,7 +885,6 @@ "Export": "Exportar", "Set Timeout": "Establecer tiempo límite", "Select Voice": "Seleccionar voz", - "Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fija", "Display what I'm reading on Discord": "Mostrar lo que estoy leyendo en Discord", "Show on Discord": "Mostrar en Discord", "Instant {{action}}": "{{action}} instantáneo", @@ -1901,5 +1898,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Mientras {{provider}} esté seleccionado, los libros, el progreso y las anotaciones se sincronizan solo con tu Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Mientras {{provider}} esté seleccionado, los libros, el progreso y las anotaciones se sincronizan solo con tu bucket.", "Premium": "Premium", - "S3 Storage": "Almacenamiento S3" + "S3 Storage": "Almacenamiento S3", + "Speed": "Velocidad", + "{{time}} left in chapter": "{{time}} restante en el capítulo", + "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" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 12ee8031..9a3a3048 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -30,7 +30,6 @@ "Edit": "ویرایش", "Excerpts": "گزیده‌ها", "Failed to import book(s): {{filenames}}": "وارد کردن کتاب(ها) «{{filenames}}» ناموفق بود.", - "Fast": "سریع", "Font": "فونت", "Font & Layout": "فونت و چیدمان", "Font Face": "نمای فونت", @@ -88,7 +87,6 @@ "Sign In": "ورود", "Sign Out": "خروج", "Sky": "آسمانی", - "Slow": "آهسته", "Solarized": "آفتابی", "Speak": "خواندن", "Subjects": "موضوعات", @@ -876,7 +874,6 @@ "Export": "خروجی", "Set Timeout": "تنظیم مهلت زمانی", "Select Voice": "انتخاب صدا", - "Toggle Sticky Bottom TTS Bar": "تغییر نوار TTS ثابت", "Display what I'm reading on Discord": "نمایش کتاب در حال خواندن در Discord", "Show on Discord": "نمایش در Discord", "Instant {{action}}": "{{action}} فوری", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "تا زمانی که {{provider}} انتخاب شده باشد، کتاب‌ها، پیشرفت و یادداشت‌ها فقط با Drive شما همگام‌سازی می‌شوند.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "تا زمانی که {{provider}} انتخاب شده باشد، کتاب‌ها، پیشرفت و یادداشت‌ها فقط با باکت شما همگام‌سازی می‌شوند.", "Premium": "پریمیوم", - "S3 Storage": "فضای ذخیره‌سازی S3" + "S3 Storage": "فضای ذخیره‌سازی S3", + "Speed": "سرعت", + "{{time}} left in chapter": "{{time}} از فصل باقی مانده", + "Open Read Aloud player": "باز کردن پخش‌کننده خواندن با صدای بلند", + "Read Aloud": "خواندن با صدای بلند", + "Voice": "صدا", + "Sleep Timer": "تایمر خواب" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 969a10a8..a885d6f1 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -30,7 +30,6 @@ "Edit": "Modifier", "Excerpts": "Extraits", "Failed to import book(s): {{filenames}}": "Échec de l'importation du/des livre(s) : {{filenames}}", - "Fast": "Rapide", "Font": "Police", "Font & Layout": "Police et mise en page", "Font Face": "Style de police", @@ -89,7 +88,6 @@ "Sign In": "Se connecter", "Sign Out": "Se déconnecter", "Sky": "Ciel", - "Slow": "Lent", "Solarized": "Solarisé", "Speak": "Lire", "Subjects": "Sujets", @@ -887,7 +885,6 @@ "Export": "Exporter", "Set Timeout": "Définir le délai", "Select Voice": "Sélectionner la voix", - "Toggle Sticky Bottom TTS Bar": "Basculer la barre TTS fixée", "Display what I'm reading on Discord": "Afficher ce que je lis sur Discord", "Show on Discord": "Afficher sur Discord", "Instant {{action}}": "{{action}} instantané", @@ -1901,5 +1898,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Tant que {{provider}} est sélectionné, les livres, la progression et les annotations ne se synchronisent qu'avec votre Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Tant que {{provider}} est sélectionné, les livres, la progression et les annotations ne se synchronisent qu'avec votre bucket.", "Premium": "Premium", - "S3 Storage": "Stockage S3" + "S3 Storage": "Stockage S3", + "Speed": "Vitesse", + "{{time}} left in chapter": "{{time}} restant dans le chapitre", + "Open Read Aloud player": "Ouvrir le lecteur de lecture à voix haute", + "Read Aloud": "Lecture à voix haute", + "Voice": "Voix", + "Sleep Timer": "Minuteur de veille" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 8102b9c9..5b60b380 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -568,14 +568,11 @@ "{{value}} hour": "שעה {{value}}", "{{value}} hours": "{{value}} שעות", "Voices for {{lang}}": "קולות עבור {{lang}}", - "Slow": "איטי", - "Fast": "מהיר", "Set Timeout": "הגדר הגבלת זמן", "Select Voice": "בחר קול", "{{engine}}: {{count}} voices_one": "{{engine}}: קול אחד", "{{engine}}: {{count}} voices_two": "{{engine}}: {{count}} קולות", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} קולות", - "Toggle Sticky Bottom TTS Bar": "הצג/הסתר שורת TTS תחתונה קבועה", "Zoom Level": "רמת זום", "Zoom Out": "הקטן", "Reset Zoom": "אפס זום", @@ -1901,5 +1898,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "כאשר {{provider}} נבחר, ספרים, התקדמות והערות מסתנכרנים רק עם ה-Drive שלך.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "כאשר {{provider}} נבחר, ספרים, התקדמות והערות מסתנכרנים רק עם הדלי שלך.", "Premium": "פרימיום", - "S3 Storage": "אחסון S3" + "S3 Storage": "אחסון S3", + "Speed": "מהירות", + "{{time}} left in chapter": "נותרו {{time}} בפרק", + "Open Read Aloud player": "פתיחת נגן ההקראה", + "Read Aloud": "הקראה", + "Voice": "קול", + "Sleep Timer": "טיימר שינה" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 004659a8..36e84bdd 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -30,7 +30,6 @@ "Edit": "संपादित करें", "Excerpts": "अंश", "Failed to import book(s): {{filenames}}": "पुस्तक(ें) आयात करने में विफल: {{filenames}}", - "Fast": "तेज़", "Font": "फ़ॉन्ट", "Font & Layout": "फ़ॉन्ट और लेआउट", "Font Face": "फ़ॉन्ट फेस", @@ -89,7 +88,6 @@ "Sign In": "साइन इन करें", "Sign Out": "साइन आउट करें", "Sky": "आकाश", - "Slow": "धीमा", "Solarized": "सोलराइज्ड", "Speak": "बोलें", "Subjects": "विषय", @@ -876,7 +874,6 @@ "Export": "निर्यात करें", "Set Timeout": "टाइमआउट सेट करें", "Select Voice": "आवाज़ चुनें", - "Toggle Sticky Bottom TTS Bar": "स्थिर TTS बार टॉगल करें", "Display what I'm reading on Discord": "Discord पर पढ़ रही किताब दिखाएं", "Show on Discord": "Discord पर दिखाएं", "Instant {{action}}": "तत्काल {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "जब {{provider}} चुना गया हो, तो किताबें, प्रगति और एनोटेशन केवल आपके Drive से सिंक होते हैं।", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "जब {{provider}} चुना गया हो, तो किताबें, प्रगति और एनोटेशन केवल आपके बकेट से सिंक होते हैं।", "Premium": "प्रीमियम", - "S3 Storage": "S3 स्टोरेज" + "S3 Storage": "S3 स्टोरेज", + "Speed": "गति", + "{{time}} left in chapter": "अध्याय में {{time}} शेष", + "Open Read Aloud player": "ज़ोर से पढ़ें प्लेयर खोलें", + "Read Aloud": "ज़ोर से पढ़ें", + "Voice": "आवाज़", + "Sleep Timer": "स्लीप टाइमर" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 8459d676..b67b8f14 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -630,13 +630,10 @@ "{{value}} hour": "{{value}} óra", "{{value}} hours": "{{value}} óra", "Voices for {{lang}}": "Hangok ehhez: {{lang}}", - "Slow": "Lassú", - "Fast": "Gyors", "Set Timeout": "Időkorlát beállítása", "Select Voice": "Hang kiválasztása", "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} hang", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} hang", - "Toggle Sticky Bottom TTS Bar": "Rögzített alsó TTS-sáv be/ki", "Zoom Level": "Nagyítási szint", "Zoom Out": "Kicsinyítés", "Reset Zoom": "Nagyítás visszaállítása", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Amíg a(z) {{provider}} van kiválasztva, a könyvek, az előrehaladás és a jegyzetek csak a Drive-odba szinkronizálódnak.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Amíg a(z) {{provider}} van kiválasztva, a könyvek, az előrehaladás és a jegyzetek csak a bucketedbe szinkronizálódnak.", "Premium": "Prémium", - "S3 Storage": "S3-tárhely" + "S3 Storage": "S3-tárhely", + "Speed": "Sebesség", + "{{time}} left in chapter": "{{time}} van hátra a fejezetből", + "Open Read Aloud player": "Felolvasó lejátszó megnyitása", + "Read Aloud": "Felolvasás", + "Voice": "Hang", + "Sleep Timer": "Elalvási időzítő" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 8b5cab16..eaff2acf 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -30,7 +30,6 @@ "Edit": "Edit", "Excerpts": "Kutipan", "Failed to import book(s): {{filenames}}": "Gagal mengimpor buku: {{filenames}}", - "Fast": "Cepat", "Font": "Font", "Font & Layout": "Font & Tata Letak", "Font Face": "Jenis Font", @@ -89,7 +88,6 @@ "Sign In": "Masuk", "Sign Out": "Keluar", "Sky": "Langit", - "Slow": "Lambat", "Solarized": "Solarized", "Speak": "Bicara", "Subjects": "Subjek", @@ -865,7 +863,6 @@ "Export": "Ekspor", "Set Timeout": "Atur batas waktu", "Select Voice": "Pilih suara", - "Toggle Sticky Bottom TTS Bar": "Alihkan bilah TTS tetap", "Display what I'm reading on Discord": "Tampilkan buku yang sedang dibaca di Discord", "Show on Discord": "Tampilkan di Discord", "Instant {{action}}": "{{action}} Instan", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Saat {{provider}} dipilih, buku, progres, dan anotasi hanya disinkronkan ke Drive Anda.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Saat {{provider}} dipilih, buku, progres, dan anotasi hanya disinkronkan ke bucket Anda.", "Premium": "Premium", - "S3 Storage": "Penyimpanan S3" + "S3 Storage": "Penyimpanan S3", + "Speed": "Kecepatan", + "{{time}} left in chapter": "{{time}} tersisa di bab ini", + "Open Read Aloud player": "Buka pemutar baca nyaring", + "Read Aloud": "Baca Nyaring", + "Voice": "Suara", + "Sleep Timer": "Timer Tidur" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 703ec1b3..6b5eadd7 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -30,7 +30,6 @@ "Edit": "Modifica", "Excerpts": "Estratti", "Failed to import book(s): {{filenames}}": "Impossibile importare il/i libro/i: {{filenames}}", - "Fast": "Veloce", "Font": "Font", "Font & Layout": "Font e Layout", "Font Face": "Tipo di carattere", @@ -89,7 +88,6 @@ "Sign In": "Accedi", "Sign Out": "Esci", "Sky": "Cielo", - "Slow": "Lento", "Solarized": "Solarizzato", "Speak": "Leggi", "Subjects": "Argomenti", @@ -887,7 +885,6 @@ "Export": "Esporta", "Set Timeout": "Imposta timeout", "Select Voice": "Seleziona voce", - "Toggle Sticky Bottom TTS Bar": "Attiva/disattiva barra TTS fissa", "Display what I'm reading on Discord": "Mostra cosa sto leggendo su Discord", "Show on Discord": "Mostra su Discord", "Instant {{action}}": "{{action}} istantaneo", @@ -1901,5 +1898,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Finché {{provider}} è selezionato, libri, progressi e annotazioni si sincronizzano solo con il tuo Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Finché {{provider}} è selezionato, libri, progressi e annotazioni si sincronizzano solo con il tuo bucket.", "Premium": "Premium", - "S3 Storage": "Archiviazione S3" + "S3 Storage": "Archiviazione S3", + "Speed": "Velocità", + "{{time}} left in chapter": "{{time}} rimanente nel capitolo", + "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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 78ed994c..0934bad2 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -30,7 +30,6 @@ "Edit": "編集", "Excerpts": "抜粋", "Failed to import book(s): {{filenames}}": "書籍のインポートに失敗しました:{{filenames}}", - "Fast": "高速", "Font": "フォント", "Font & Layout": "フォントとレイアウト", "Font Face": "書体", @@ -89,7 +88,6 @@ "Sign In": "サインイン", "Sign Out": "サインアウト", "Sky": "スカイ", - "Slow": "低速", "Solarized": "ソーラライズド", "Speak": "読み上げ", "Subjects": "主題", @@ -865,7 +863,6 @@ "Export": "エクスポート", "Set Timeout": "タイムアウト設定", "Select Voice": "音声選択", - "Toggle Sticky Bottom TTS Bar": "TTSバー固定切替", "Display what I'm reading on Discord": "読書中の本をDiscordに表示", "Show on Discord": "Discordに表示", "Instant {{action}}": "インスタント{{action}}", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} を選択している間、書籍・進捗・注釈は Drive にのみ同期されます。", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} を選択している間、書籍・進捗・注釈はバケットにのみ同期されます。", "Premium": "プレミアム", - "S3 Storage": "S3 ストレージ" + "S3 Storage": "S3 ストレージ", + "Speed": "速度", + "{{time}} left in chapter": "この章の残り {{time}}", + "Open Read Aloud player": "読み上げプレーヤーを開く", + "Read Aloud": "読み上げ", + "Voice": "音声", + "Sleep Timer": "スリープタイマー" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index e335d8f3..2a550e7d 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -30,7 +30,6 @@ "Edit": "편집", "Excerpts": "발췌", "Failed to import book(s): {{filenames}}": "책 가져오기 실패: {{filenames}}", - "Fast": "빠름", "Font": "글꼴", "Font & Layout": "글꼴 및 레이아웃", "Font Face": "글꼴 스타일", @@ -89,7 +88,6 @@ "Sign In": "로그인", "Sign Out": "로그아웃", "Sky": "하늘색", - "Slow": "느림", "Solarized": "솔라라이즈드", "Speak": "말하기", "Subjects": "주제", @@ -865,7 +863,6 @@ "Export": "내보내기", "Set Timeout": "시간 제한 설정", "Select Voice": "음성 선택", - "Toggle Sticky Bottom TTS Bar": "고정 TTS 바 전환", "Display what I'm reading on Discord": "Discord에 읽는 책 표시", "Show on Discord": "Discord에 표시", "Instant {{action}}": "즉시 {{action}}", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}}를 선택하면 책, 진행률, 주석이 Drive에만 동기화됩니다.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}}를 선택하면 책, 진행률, 주석이 버킷에만 동기화됩니다.", "Premium": "프리미엄", - "S3 Storage": "S3 스토리지" + "S3 Storage": "S3 스토리지", + "Speed": "속도", + "{{time}} left in chapter": "이 장의 남은 시간 {{time}}", + "Open Read Aloud player": "소리 내어 읽기 플레이어 열기", + "Read Aloud": "소리 내어 읽기", + "Voice": "음성", + "Sleep Timer": "수면 타이머" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 82cbe261..3dc0e1f6 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -296,8 +296,6 @@ "{{value}} hour": "{{value}} jam", "{{value}} hours": "{{value}} jam", "Voices for {{lang}}": "Suara untuk {{lang}}", - "Slow": "Perlahan", - "Fast": "Pantas", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} suara", "Zoom Level": "Tahap Zum", "Zoom Out": "Zum Keluar", @@ -865,7 +863,6 @@ "Export": "Eksport", "Set Timeout": "Tetapkan masa tamat", "Select Voice": "Pilih suara", - "Toggle Sticky Bottom TTS Bar": "Togol bar TTS melekit", "Display what I'm reading on Discord": "Papar buku yang sedang dibaca di Discord", "Show on Discord": "Papar di Discord", "Instant {{action}}": "{{action}} Segera", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Semasa {{provider}} dipilih, buku, kemajuan dan anotasi hanya disegerakkan ke Drive anda.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Semasa {{provider}} dipilih, buku, kemajuan dan anotasi hanya disegerakkan ke bucket anda.", "Premium": "Premium", - "S3 Storage": "Storan S3" + "S3 Storage": "Storan S3", + "Speed": "Kelajuan", + "{{time}} left in chapter": "{{time}} berbaki dalam bab", + "Open Read Aloud player": "Buka pemain baca lantang", + "Read Aloud": "Baca Lantang", + "Voice": "Suara", + "Sleep Timer": "Pemasa Tidur" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 907734b5..f40239b5 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -208,8 +208,6 @@ "{{value}} minutes": "{{value}} minuten", "{{value}} hour": "{{value}} uur", "{{value}} hours": "{{value}} uren", - "Slow": "Langzaam", - "Fast": "Snel", "Reading Progress Synced": "Leesvoortgang gesynchroniseerd", "Failed to delete user. Please try again later.": "Gebruiker verwijderen mislukt. Probeer het later opnieuw.", "Community Support": "Community-ondersteuning", @@ -876,7 +874,6 @@ "Export": "Exporteren", "Set Timeout": "Time-out instellen", "Select Voice": "Stem selecteren", - "Toggle Sticky Bottom TTS Bar": "Vaste TTS-balk schakelen", "Display what I'm reading on Discord": "Toon wat ik lees op Discord", "Show on Discord": "Toon op Discord", "Instant {{action}}": "Directe {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Zolang {{provider}} is geselecteerd, worden boeken, voortgang en annotaties alleen met uw Drive gesynchroniseerd.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Zolang {{provider}} is geselecteerd, worden boeken, voortgang en annotaties alleen met uw bucket gesynchroniseerd.", "Premium": "Premium", - "S3 Storage": "S3-opslag" + "S3 Storage": "S3-opslag", + "Speed": "Snelheid", + "{{time}} left in chapter": "{{time}} resterend in hoofdstuk", + "Open Read Aloud player": "Voorleesspeler openen", + "Read Aloud": "Voorlezen", + "Voice": "Stem", + "Sleep Timer": "Slaaptimer" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 5b3dbdd5..c264a77c 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -30,7 +30,6 @@ "Edit": "Edytuj", "Excerpts": "Fragmenty", "Failed to import book(s): {{filenames}}": "Nie udało się zaimportować książek: {{filenames}}", - "Fast": "Szybko", "Font": "Czcionka", "Font & Layout": "Czcionka i układ", "Font Face": "Krój czcionki", @@ -89,7 +88,6 @@ "Sign In": "Zaloguj się", "Sign Out": "Wyloguj się", "Sky": "Niebieski", - "Slow": "Wolno", "Solarized": "Słoneczny", "Speak": "Czytaj", "Subjects": "Tematy", @@ -898,7 +896,6 @@ "Export": "Eksportuj", "Set Timeout": "Ustaw limit czasu", "Select Voice": "Wybierz głos", - "Toggle Sticky Bottom TTS Bar": "Przełącz przypiętą dolną belkę TTS", "Display what I'm reading on Discord": "Wyświetl to, co czytam na Discord", "Show on Discord": "Pokaż na Discord", "Instant {{action}}": "Natychmiastowe {{action}}", @@ -1936,5 +1933,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Gdy wybrany jest {{provider}}, książki, postęp i adnotacje synchronizują się tylko z Twoim Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Gdy wybrany jest {{provider}}, książki, postęp i adnotacje synchronizują się tylko z Twoim bucketem.", "Premium": "Premium", - "S3 Storage": "Magazyn S3" + "S3 Storage": "Magazyn S3", + "Speed": "Prędkość", + "{{time}} left in chapter": "Pozostało {{time}} w rozdziale", + "Open Read Aloud player": "Otwórz odtwarzacz czytania na głos", + "Read Aloud": "Czytanie na głos", + "Voice": "Głos", + "Sleep Timer": "Minutnik snu" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index a4889713..bf1b63df 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -701,14 +701,11 @@ "{{value}} hour": "{{value}} hora", "{{value}} hours": "{{value}} horas", "Voices for {{lang}}": "Vozes para {{lang}}", - "Slow": "Lento", - "Fast": "Rápido", "Set Timeout": "Definir tempo limite", "Select Voice": "Selecionar voz", "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voz", "{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} vozes", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} vozes", - "Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fixada", "Zoom Level": "Nível de zoom", "Zoom Out": "Reduzir zoom", "Reset Zoom": "Redefinir zoom", @@ -1901,5 +1898,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Enquanto o {{provider}} estiver selecionado, livros, progresso e anotações são sincronizados apenas com seu Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Enquanto o {{provider}} estiver selecionado, livros, progresso e anotações são sincronizados apenas com seu bucket.", "Premium": "Premium", - "S3 Storage": "Armazenamento S3" + "S3 Storage": "Armazenamento S3", + "Speed": "Velocidade", + "{{time}} left in chapter": "{{time}} restante no capítulo", + "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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index dd586d5a..364b7887 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -30,7 +30,6 @@ "Edit": "Editar", "Excerpts": "Trechos", "Failed to import book(s): {{filenames}}": "Falha ao importar livro(s): {{filenames}}", - "Fast": "Rápido", "Font": "Fonte", "Font & Layout": "Fonte e Layout", "Font Face": "Estilo da Fonte", @@ -89,7 +88,6 @@ "Sign In": "Entrar", "Sign Out": "Sair", "Sky": "Céu", - "Slow": "Lento", "Solarized": "Solarizado", "Speak": "Falar", "Subjects": "Assuntos", @@ -887,7 +885,6 @@ "Export": "Exportar", "Set Timeout": "Definir tempo limite", "Select Voice": "Selecionar voz", - "Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fixada", "Display what I'm reading on Discord": "Mostrar o que estou lendo no Discord", "Show on Discord": "Mostrar no Discord", "Instant {{action}}": "{{action}} instantâneo", @@ -1901,5 +1898,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Enquanto o {{provider}} estiver selecionado, os livros, o progresso e as anotações são sincronizados apenas com o seu Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Enquanto o {{provider}} estiver selecionado, os livros, o progresso e as anotações são sincronizados apenas com o seu bucket.", "Premium": "Premium", - "S3 Storage": "Armazenamento S3" + "S3 Storage": "Armazenamento S3", + "Speed": "Velocidade", + "{{time}} left in chapter": "{{time}} restante no capítulo", + "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" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index ad638cf8..23f67bc4 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -623,14 +623,11 @@ "{{value}} hour": "{{value}} oră", "{{value}} hours": "{{value}} ore", "Voices for {{lang}}": "Voci pentru {{lang}}", - "Slow": "Încet", - "Fast": "Rapid", "Set Timeout": "Setează limita de timp", "Select Voice": "Selectează vocea", "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voce", "{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} voci", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} de voci", - "Toggle Sticky Bottom TTS Bar": "Comută bara TTS fixată jos", "Zoom Level": "Nivel de zoom", "Zoom Out": "Micșorare", "Reset Zoom": "Resetează zoomul", @@ -1901,5 +1898,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Cât timp {{provider}} este selectat, cărțile, progresul și adnotările se sincronizează doar cu Drive-ul tău.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Cât timp {{provider}} este selectat, cărțile, progresul și adnotările se sincronizează doar cu bucketul tău.", "Premium": "Premium", - "S3 Storage": "Stocare S3" + "S3 Storage": "Stocare S3", + "Speed": "Viteză", + "{{time}} left in chapter": "{{time}} rămas în capitol", + "Open Read Aloud player": "Deschide playerul de citire cu voce tare", + "Read Aloud": "Citire cu voce tare", + "Voice": "Voce", + "Sleep Timer": "Temporizator de somn" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index bc93133c..e738b585 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -30,7 +30,6 @@ "Edit": "Редактировать", "Excerpts": "Отрывки", "Failed to import book(s): {{filenames}}": "Не удалось импортировать книгу(и): {{filenames}}", - "Fast": "Быстро", "Font": "Шрифт", "Font & Layout": "Шрифт и макет", "Font Face": "Начертание шрифта", @@ -89,7 +88,6 @@ "Sign In": "Войти", "Sign Out": "Выйти", "Sky": "Небесный", - "Slow": "Медленно", "Solarized": "Солнечный", "Speak": "Произнести", "Subjects": "Темы", @@ -898,7 +896,6 @@ "Export": "Экспортировать", "Set Timeout": "Установить тайм-аут", "Select Voice": "Выбрать голос", - "Toggle Sticky Bottom TTS Bar": "Переключить закреплённую панель TTS", "Display what I'm reading on Discord": "Показывать книгу в Discord", "Show on Discord": "Показать в Discord", "Instant {{action}}": "Мгновенное {{action}}", @@ -1936,5 +1933,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Пока выбран {{provider}}, книги, прогресс и аннотации синхронизируются только с вашим Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Пока выбран {{provider}}, книги, прогресс и аннотации синхронизируются только с вашим бакетом.", "Premium": "Премиум", - "S3 Storage": "Хранилище S3" + "S3 Storage": "Хранилище S3", + "Speed": "Скорость", + "{{time}} left in chapter": "Осталось {{time}} в главе", + "Open Read Aloud player": "Открыть плеер чтения вслух", + "Read Aloud": "Чтение вслух", + "Voice": "Голос", + "Sleep Timer": "Таймер сна" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index b26a7d6d..52072096 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -320,8 +320,6 @@ "{{value}} hour": "පැය {{value}}", "{{value}} hours": "පැය {{value}}", "Voices for {{lang}}": "{{lang}} සඳහා හඬ", - "Slow": "මන්දගාමී", - "Fast": "වේගවත්", "{{engine}}: {{count}} voices_one": "{{engine}}: හඬ 1", "{{engine}}: {{count}} voices_other": "{{engine}}: හඬ {{count}}", "Sign in to Sync": "සමමුහුර්ත කිරීම සඳහා ඇතුල් වන්න", @@ -876,7 +874,6 @@ "Export": "අපනයනය කරන්න", "Set Timeout": "කාල සීමාව සකසන්න", "Select Voice": "හඬ තෝරන්න", - "Toggle Sticky Bottom TTS Bar": "ඇලවූ TTS තීරුව මාරු කරන්න", "Display what I'm reading on Discord": "Discord හි කියවන පොත පෙන්වන්න", "Show on Discord": "Discord හි පෙන්වන්න", "Instant {{action}}": "ක්ෂණික {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} තෝරා ඇති විට, පොත්, ප්‍රගතිය සහ සටහන් ඔබේ Drive වෙත පමණක් සමමුහුර්ත වේ.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} තෝරා ඇති විට, පොත්, ප්‍රගතිය සහ සටහන් ඔබේ බකට් එකට පමණක් සමමුහුර්ත වේ.", "Premium": "ප්‍රිමියම්", - "S3 Storage": "S3 ගබඩාව" + "S3 Storage": "S3 ගබඩාව", + "Speed": "වේගය", + "{{time}} left in chapter": "පරිච්ඡේදයේ {{time}} ඉතිරිය", + "Open Read Aloud player": "ශබ්ද නඟා කියවීමේ ධාවකය විවෘත කරන්න", + "Read Aloud": "ශබ්ද නඟා කියවීම", + "Voice": "හඬ", + "Sleep Timer": "නින්ද කාල ගණකය" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index aa63e271..3835ab39 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -596,15 +596,12 @@ "{{value}} hour": "{{value}} ura", "{{value}} hours": "{{value}} ur", "Voices for {{lang}}": "Glasovi za {{lang}}", - "Slow": "Počasi", - "Fast": "Hitro", "Set Timeout": "Nastavi časovno omejitev", "Select Voice": "Izberi glas", "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} glas", "{{engine}}: {{count}} voices_two": "{{engine}}: {{count}} glasova", "{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} glasovi", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} glasov", - "Toggle Sticky Bottom TTS Bar": "Preklopi fiksno spodnjo TTS vrstico", "Zoom Level": "Raven povečave", "Zoom Out": "Pomanjšaj", "Reset Zoom": "Ponastavi povečavo", @@ -1936,5 +1933,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Dokler je izbran {{provider}}, se knjige, napredek in opombe sinhronizirajo samo z vašim Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Dokler je izbran {{provider}}, se knjige, napredek in opombe sinhronizirajo samo z vašim vedrom.", "Premium": "Premium", - "S3 Storage": "Shramba S3" + "S3 Storage": "Shramba S3", + "Speed": "Hitrost", + "{{time}} left in chapter": "{{time}} preostalo v poglavju", + "Open Read Aloud player": "Odpri predvajalnik glasnega branja", + "Read Aloud": "Glasno branje", + "Voice": "Glas", + "Sleep Timer": "Časovnik spanja" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 4dfbffd9..f1261fe5 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -278,8 +278,6 @@ "{{value}} hour": "{{value}} timme", "{{value}} hours": "{{value}} timmar", "Voices for {{lang}}": "Röster för {{lang}}", - "Slow": "Långsam", - "Fast": "Snabb", "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} röst", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} röster", "Zoom Level": "Zoomnivå", @@ -876,7 +874,6 @@ "Export": "Exportera", "Set Timeout": "Ställ in timeout", "Select Voice": "Välj röst", - "Toggle Sticky Bottom TTS Bar": "Växla fast TTS-fält", "Display what I'm reading on Discord": "Visa vad jag läser på Discord", "Show on Discord": "Visa på Discord", "Instant {{action}}": "Omedelbar {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "När {{provider}} är valt synkroniseras böcker, framsteg och anteckningar endast med din Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "När {{provider}} är valt synkroniseras böcker, framsteg och anteckningar endast med din bucket.", "Premium": "Premium", - "S3 Storage": "S3-lagring" + "S3 Storage": "S3-lagring", + "Speed": "Hastighet", + "{{time}} left in chapter": "{{time}} kvar i kapitlet", + "Open Read Aloud player": "Öppna uppläsningsspelaren", + "Read Aloud": "Uppläsning", + "Voice": "Röst", + "Sleep Timer": "Sovtimer" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 8174c27b..ab6f349b 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -320,8 +320,6 @@ "{{value}} hour": "{{value}} மணிநேரம்", "{{value}} hours": "{{value}} மணிநேரங்கள்", "Voices for {{lang}}": "{{lang}} க்கான குரல்கள்", - "Slow": "மெதுவாக", - "Fast": "வேகமாக", "{{engine}}: {{count}} voices_one": "{{engine}}: 1 குரல்", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} குரல்கள்", "Sign in to Sync": "ஒத்திசைக்க உள்நுழையவும்", @@ -876,7 +874,6 @@ "Export": "ஏற்றுமதி", "Set Timeout": "நேர வரம்பை அமைக்கவும்", "Select Voice": "குரலைத் தேர்ந்தெடுக்கவும்", - "Toggle Sticky Bottom TTS Bar": "நிலையான TTS பட்டியை மாற்றவும்", "Display what I'm reading on Discord": "Discord இல் படிக்கும் புத்தகத்தை காட்டு", "Show on Discord": "Discord இல் காட்டு", "Instant {{action}}": "உடனடி {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} தேர்ந்தெடுக்கப்பட்டிருக்கும் போது, புத்தகங்கள், முன்னேற்றம் மற்றும் குறிப்புகள் உங்கள் Drive உடன் மட்டுமே ஒத்திசைக்கப்படும்.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} தேர்ந்தெடுக்கப்பட்டிருக்கும் போது, புத்தகங்கள், முன்னேற்றம் மற்றும் குறிப்புகள் உங்கள் பக்கெட்டுடன் மட்டுமே ஒத்திசைக்கப்படும்.", "Premium": "பிரீமியம்", - "S3 Storage": "S3 சேமிப்பகம்" + "S3 Storage": "S3 சேமிப்பகம்", + "Speed": "வேகம்", + "{{time}} left in chapter": "அத்தியாயத்தில் {{time}} மீதம்", + "Open Read Aloud player": "உரக்க வாசித்தல் பிளேயரைத் திற", + "Read Aloud": "உரக்க வாசித்தல்", + "Voice": "குரல்", + "Sleep Timer": "ஸ்லீப் டைமர்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 487fa6a4..47fa7d54 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -271,8 +271,6 @@ "{{value}} minutes": "{{value}} นาที", "{{value}} hour": "{{value}} ชั่วโมง", "{{value}} hours": "{{value}} ชั่วโมง", - "Slow": "ช้า", - "Fast": "เร็ว", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} เสียง", "Sign in to Sync": "เข้าสู่ระบบเพื่อซิงค์", "Never synced": "ไม่เคยซิงค์", @@ -865,7 +863,6 @@ "Export": "ส่งออก", "Set Timeout": "ตั้งค่าหมดเวลา", "Select Voice": "เลือกเสียง", - "Toggle Sticky Bottom TTS Bar": "สลับแถบ TTS แบบติด", "Display what I'm reading on Discord": "แสดงหนังสือที่กำลังอ่านบน Discord", "Show on Discord": "แสดงบน Discord", "Instant {{action}}": "{{action}}ทันที", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "เมื่อเลือก {{provider}} หนังสือ ความคืบหน้า และคำอธิบายประกอบจะซิงค์ไปยัง Drive ของคุณเท่านั้น", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "เมื่อเลือก {{provider}} หนังสือ ความคืบหน้า และคำอธิบายประกอบจะซิงค์ไปยังบักเก็ตของคุณเท่านั้น", "Premium": "พรีเมียม", - "S3 Storage": "พื้นที่จัดเก็บ S3" + "S3 Storage": "พื้นที่จัดเก็บ S3", + "Speed": "ความเร็ว", + "{{time}} left in chapter": "เหลือ {{time}} ในบทนี้", + "Open Read Aloud player": "เปิดเครื่องเล่นอ่านออกเสียง", + "Read Aloud": "อ่านออกเสียง", + "Voice": "เสียง", + "Sleep Timer": "ตั้งเวลาปิด" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index bc586c86..4150f771 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -30,7 +30,6 @@ "Edit": "Düzenle", "Excerpts": "Alıntılar", "Failed to import book(s): {{filenames}}": "Kitap(lar) içe aktarılamadı: {{filenames}}", - "Fast": "Hızlı", "Font": "Yazı Tipi", "Font & Layout": "Yazı Tipi ve Düzen", "Font Face": "Yazı Tipi Yüzü", @@ -89,7 +88,6 @@ "Sign In": "Giriş Yap", "Sign Out": "Çıkış Yap", "Sky": "Gökyüzü", - "Slow": "Yavaş", "Solarized": "Solarized", "Speak": "Konuş", "Subjects": "Konular", @@ -876,7 +874,6 @@ "Export": "Dışa Aktar", "Set Timeout": "Zaman aşımını ayarla", "Select Voice": "Ses seç", - "Toggle Sticky Bottom TTS Bar": "Sabit TTS çubuğunu değiştir", "Display what I'm reading on Discord": "Discord'da okuduğumu göster", "Show on Discord": "Discord'da göster", "Instant {{action}}": "Anında {{action}}", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} seçili olduğunda kitaplar, ilerleme ve açıklamalar yalnızca Drive'ınızla senkronize edilir.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} seçili olduğunda kitaplar, ilerleme ve açıklamalar yalnızca bucket'ınızla senkronize edilir.", "Premium": "Premium", - "S3 Storage": "S3 depolama" + "S3 Storage": "S3 depolama", + "Speed": "Hız", + "{{time}} left in chapter": "Bölümde {{time}} kaldı", + "Open Read Aloud player": "Sesli okuma oynatıcısını aç", + "Read Aloud": "Sesli Okuma", + "Voice": "Ses", + "Sleep Timer": "Uyku Zamanlayıcısı" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 3e05bf80..c1c4858b 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -30,7 +30,6 @@ "Edit": "Редагувати", "Excerpts": "Уривки", "Failed to import book(s): {{filenames}}": "Не вдалося імпортувати книгу(и): {{filenames}}", - "Fast": "Швидко", "Font": "Шрифт", "Font & Layout": "Шрифт та макет", "Font Face": "Налаштування шрифту", @@ -89,7 +88,6 @@ "Sign In": "Увійти", "Sign Out": "Вийти", "Sky": "Небесний", - "Slow": "Повільно", "Solarized": "Сонячний", "Speak": "Озвучити", "Subjects": "Жанри", @@ -898,7 +896,6 @@ "Export": "Експортувати", "Set Timeout": "Встановити тайм-аут", "Select Voice": "Вибрати голос", - "Toggle Sticky Bottom TTS Bar": "Перемкнути закріплену панель TTS", "Display what I'm reading on Discord": "Показувати книгу в Discord", "Show on Discord": "Показати в Discord", "Instant {{action}}": "Швидка дія: {{action}}", @@ -1936,5 +1933,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Поки вибрано {{provider}}, книги, прогрес і анотації синхронізуються лише з вашим Drive.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Поки вибрано {{provider}}, книги, прогрес і анотації синхронізуються лише з вашим бакетом.", "Premium": "Преміум", - "S3 Storage": "Сховище S3" + "S3 Storage": "Сховище S3", + "Speed": "Швидкість", + "{{time}} left in chapter": "Залишилося {{time}} у розділі", + "Open Read Aloud player": "Відкрити програвач читання вголос", + "Read Aloud": "Читання вголос", + "Voice": "Голос", + "Sleep Timer": "Таймер сну" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index e8e8d203..acb0d884 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -689,13 +689,10 @@ "{{value}} hour": "{{value}} soat", "{{value}} hours": "{{value}} soat", "Voices for {{lang}}": "{{lang}} uchun ovozlar", - "Slow": "Sekin", - "Fast": "Tez", "Set Timeout": "Vaqt cheklovini belgilash", "Select Voice": "Ovozni tanlash", "{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} ovoz", "{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} ovoz", - "Toggle Sticky Bottom TTS Bar": "Pastki yopishqoq TTS panelini ochish/yopish", "Zoom Level": "Kattalashtirish darajasi", "Zoom Out": "Kichraytirish", "Reset Zoom": "Kattalashtirishni tiklash", @@ -1866,5 +1863,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "{{provider}} tanlangan paytda kitoblar, jarayon va izohlar faqat Drive'ingizga sinxronlanadi.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "{{provider}} tanlangan paytda kitoblar, jarayon va izohlar faqat bucketingizga sinxronlanadi.", "Premium": "Premium", - "S3 Storage": "S3 saqlash" + "S3 Storage": "S3 saqlash", + "Speed": "Tezlik", + "{{time}} left in chapter": "Bobda {{time}} qoldi", + "Open Read Aloud player": "Ovoz chiqarib oʻqish pleyerini ochish", + "Read Aloud": "Ovoz chiqarib oʻqish", + "Voice": "Ovoz", + "Sleep Timer": "Uyqu taymeri" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 04598f3a..9a17e1f2 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -30,7 +30,6 @@ "Edit": "Chỉnh sửa", "Excerpts": "Trích dẫn", "Failed to import book(s): {{filenames}}": "Không thể nhập sách: {{filenames}}", - "Fast": "Nhanh", "Font": "Phông chữ", "Font & Layout": "Phông chữ & Bố cục", "Font Face": "Kiểu chữ", @@ -89,7 +88,6 @@ "Sign In": "Đăng nhập", "Sign Out": "Đăng xuất", "Sky": "Xanh trời", - "Slow": "Chậm", "Solarized": "Solarized", "Speak": "Đọc", "Subjects": "Chủ đề", @@ -865,7 +863,6 @@ "Export": "Xuất", "Set Timeout": "Đặt thời gian chờ", "Select Voice": "Chọn giọng nói", - "Toggle Sticky Bottom TTS Bar": "Bật/tắt thanh TTS cố định", "Display what I'm reading on Discord": "Hiển thị sách đang đọc trên Discord", "Show on Discord": "Hiện trên Discord", "Instant {{action}}": "{{action}} tức thì", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "Khi {{provider}} được chọn, sách, tiến độ và chú thích chỉ đồng bộ với Drive của bạn.", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "Khi {{provider}} được chọn, sách, tiến độ và chú thích chỉ đồng bộ với bucket của bạn.", "Premium": "Premium", - "S3 Storage": "Lưu trữ S3" + "S3 Storage": "Lưu trữ S3", + "Speed": "Tốc độ", + "{{time}} left in chapter": "Còn {{time}} trong chương", + "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ủ" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 95e6302c..0fa72c94 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -30,7 +30,6 @@ "Edit": "编辑", "Excerpts": "摘录", "Failed to import book(s): {{filenames}}": "导入书籍失败:{{filenames}}", - "Fast": "快", "Font": "字体", "Font & Layout": "字体和布局", "Font Face": "字型", @@ -90,7 +89,6 @@ "Sign In": "登录", "Sign Out": "登出账户", "Sky": "天青", - "Slow": "慢", "Solarized": "日晖", "Speak": "朗读", "Subjects": "主题", @@ -866,7 +864,6 @@ "Export": "导出", "Set Timeout": "设置超时", "Select Voice": "选择语音", - "Toggle Sticky Bottom TTS Bar": "切换固定TTS栏", "Display what I'm reading on Discord": "在Discord显示阅读状态", "Show on Discord": "在Discord显示", "Instant {{action}}": "即时{{action}}", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "选择 {{provider}} 后,书籍、进度和标注仅同步到您的 Drive。", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "选择 {{provider}} 后,书籍、进度和标注仅同步到您的存储桶。", "Premium": "高级版", - "S3 Storage": "S3 存储" + "S3 Storage": "S3 存储", + "Speed": "语速", + "{{time}} left in chapter": "本章剩余 {{time}}", + "Open Read Aloud player": "打开朗读播放器", + "Read Aloud": "朗读", + "Voice": "语音", + "Sleep Timer": "定时关闭" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 10b6e785..e8f422f9 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -30,7 +30,6 @@ "Edit": "編輯", "Excerpts": "摘錄", "Failed to import book(s): {{filenames}}": "導入書籍失敗:{{filenames}}", - "Fast": "快", "Font": "字體", "Font & Layout": "字體和版面", "Font Face": "字型", @@ -89,7 +88,6 @@ "Sign In": "登入", "Sign Out": "登出", "Sky": "天青", - "Slow": "慢", "Solarized": "日暉", "Speak": "朗讀", "Subjects": "主題", @@ -865,7 +863,6 @@ "Export": "匯出", "Set Timeout": "設定逾時", "Select Voice": "選擇語音", - "Toggle Sticky Bottom TTS Bar": "切換固定TTS欄", "Display what I'm reading on Discord": "在Discord顯示閱讀狀態", "Show on Discord": "在Discord顯示", "Instant {{action}}": "即時{{action}}", @@ -1831,5 +1828,11 @@ "While {{provider}} is selected, books, progress, and annotations sync only to your Drive.": "選擇 {{provider}} 後,書籍、進度和標註僅同步到您的 Drive。", "While {{provider}} is selected, books, progress, and annotations sync only to your bucket.": "選擇 {{provider}} 後,書籍、進度和標註僅同步到您的儲存桶。", "Premium": "進階版", - "S3 Storage": "S3 儲存" + "S3 Storage": "S3 儲存", + "Speed": "語速", + "{{time}} left in chapter": "本章剩餘 {{time}}", + "Open Read Aloud player": "開啟朗讀播放器", + "Read Aloud": "朗讀", + "Voice": "語音", + "Sleep Timer": "定時關閉" } diff --git a/apps/readest-app/src/__tests__/components/tts/SpeedChips.test.tsx b/apps/readest-app/src/__tests__/components/tts/SpeedChips.test.tsx new file mode 100644 index 00000000..c55a1f97 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/SpeedChips.test.tsx @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +import SpeedChips from '@/app/reader/components/tts/SpeedChips'; + +describe('SpeedChips', () => { + afterEach(() => { + cleanup(); + }); + + test('renders the presets and marks the active rate', () => { + render(); + const active = screen.getByRole('radio', { checked: true }); + expect(active.textContent).toBe('1×'); + expect(screen.getAllByRole('radio').length).toBe(11); + }); + + test('an off-preset persisted rate appears as an extra active chip in order', () => { + render(); + const chips = screen.getAllByRole('radio'); + expect(chips.length).toBe(12); + const labels = chips.map((c) => c.textContent); + expect(labels.indexOf('1.3×')).toBe(labels.indexOf('1.25×') + 1); + expect(screen.getByRole('radio', { checked: true }).textContent).toBe('1.3×'); + }); + + test('selecting a chip reports the numeric rate', () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole('radio', { name: '1.5×' })); + expect(onSelect).toHaveBeenCalledWith(1.5); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/TTSControl.test.tsx b/apps/readest-app/src/__tests__/components/tts/TTSControl.test.tsx new file mode 100644 index 00000000..d9b1999f --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/TTSControl.test.tsx @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +vi.mock('@/store/themeStore', () => ({ + useThemeStore: () => ({ safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 } }), +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + hoveredBookKey: '', + getViewSettings: () => ({ isEink: false, rtl: false }), + }), +})); + +const ttsState: Record = {}; +vi.mock('@/app/reader/hooks/useTTSControl', () => ({ + useTTSControl: () => ttsState, +})); + +vi.mock('@/app/reader/components/tts/TTSMiniPlayer', () => ({ + __esModule: true, + TTS_MINI_PLAYER_CLEARANCE: 64, + default: ({ onExpand }: { onExpand: () => void }) => ( +
+ ), +})); + +vi.mock('@/app/reader/components/tts/TTSPlayerSheet', () => ({ + __esModule: true, + default: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null, +})); + +import TTSControl from '@/app/reader/components/tts/TTSControl'; + +const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + +describe('TTSControl', () => { + beforeEach(() => { + Object.assign(ttsState, { + isPlaying: true, + ttsLang: 'en', + ttsClientsInited: true, + showIndicator: true, + showBackToCurrentTTSLocation: false, + timeoutOption: 0, + timeoutTimestamp: 0, + chapterRemainingSec: null, + handleTogglePlay: vi.fn(), + handleBackward: vi.fn(), + handleForward: vi.fn(), + handleSetRate: vi.fn(), + handleGetVoices: vi.fn(), + handleSetVoice: vi.fn(), + handleGetVoiceId: vi.fn().mockReturnValue(''), + handleSelectTimeout: vi.fn(), + handleBackToCurrentTTSLocation: vi.fn(), + handleSeekTo: vi.fn(), + handleGetPlaybackInfo: vi.fn().mockReturnValue(null), + handleSupportsPlaybackInfo: vi.fn().mockReturnValue(true), + refreshTtsLang: vi.fn(), + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('mounts the mini player while a session is active', () => { + render(); + expect(screen.getByTestId('mini-player')).toBeTruthy(); + expect(screen.queryByTestId('player-sheet')).toBeNull(); + }); + + test('renders nothing before the clients are initialized', () => { + Object.assign(ttsState, { showIndicator: false, ttsClientsInited: false }); + render(); + expect(screen.queryByTestId('mini-player')).toBeNull(); + expect(screen.queryByTestId('player-sheet')).toBeNull(); + }); + + test('expanding the mini player opens the sheet and hides the mini player', () => { + render(); + fireEvent.click(screen.getByTestId('mini-player')); + expect(screen.getByTestId('player-sheet')).toBeTruthy(); + // The two surfaces never show at the same time. + expect(screen.queryByTestId('mini-player')).toBeNull(); + }); + + test('shows the back-to-TTS-location pill when reading has drifted', () => { + Object.assign(ttsState, { showBackToCurrentTTSLocation: true }); + render(); + expect(screen.getByText('Back to TTS Location')).toBeTruthy(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/TTSMiniPlayer.test.tsx b/apps/readest-app/src/__tests__/components/tts/TTSMiniPlayer.test.tsx new file mode 100644 index 00000000..a79c5aff --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/TTSMiniPlayer.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string, opts?: Record) => + opts ? Object.entries(opts).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) : key, +})); + +vi.mock('@/hooks/useResponsiveSize', () => ({ + useResponsiveSize: (size: number) => size, +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ + appService: { isMobile: false, hasSafeAreaInset: false }, + }), +})); + +const readerState = { hoveredBookKey: '', setHoveredBookKey: vi.fn() }; +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => readerState, +})); + +vi.mock('@/store/readerProgressStore', () => ({ + useBookProgress: () => ({ sectionLabel: 'Chapter 5' }), +})); + +const getBookData = vi.fn(); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData }), +})); + +import TTSMiniPlayer from '@/app/reader/components/tts/TTSMiniPlayer'; + +const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + +const makeProps = (overrides: Record = {}) => ({ + bookKey: 'b1', + isPlaying: true, + isEink: false, + hasTimeline: true, + timeoutTimestamp: 0, + chapterRemainingSec: null as number | null, + gridInsets, + onTogglePlay: vi.fn(), + onBackward: vi.fn(), + onForward: vi.fn(), + onStop: vi.fn(), + onExpand: vi.fn(), + onGetPlaybackInfo: vi + .fn() + .mockReturnValue({ position: 10, duration: 100, measuredFraction: 0.4 }), + ...overrides, +}); + +describe('TTSMiniPlayer', () => { + beforeEach(() => { + readerState.hoveredBookKey = ''; + getBookData.mockReturnValue({ book: { title: 'Alice in Wonderland', coverImageUrl: null } }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('shows title, section label, and elapsed/remaining time', () => { + render(); + expect(screen.getByText('Alice in Wonderland')).toBeTruthy(); + expect(screen.getByText(/Chapter 5/)).toBeTruthy(); + expect(screen.getByText(/0:10/)).toBeTruthy(); + expect(screen.getByText(/-1:30/)).toBeTruthy(); + }); + + test('sentence skips and play/pause drive the transport callbacks', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Previous Sentence')); + expect(props.onBackward).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText('Next Sentence')); + expect(props.onForward).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText('Pause')); + expect(props.onTogglePlay).toHaveBeenCalled(); + expect(screen.getByLabelText('Next Sentence').closest('[dir="ltr"]')).toBeTruthy(); + }); + + test('stop button stops without expanding', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Stop reading aloud')); + expect(props.onStop).toHaveBeenCalled(); + expect(props.onExpand).not.toHaveBeenCalled(); + }); + + test('tapping the body expands the player sheet', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByText('Alice in Wonderland')); + expect(props.onExpand).toHaveBeenCalled(); + }); + + test('fades out while the footer bar is up for this book', () => { + readerState.hoveredBookKey = 'b1'; + render(); + expect(screen.getByRole('status').className).toContain('pointer-events-none'); + }); + + test('without a timeline shows the estimated chapter remaining instead', () => { + render( + , + ); + expect(screen.getByText(/5:00 left in chapter/)).toBeTruthy(); + expect(screen.queryByText(/-1:30/)).toBeNull(); + }); + + test('shows a countdown chip while a sleep timer is armed', () => { + vi.useFakeTimers(); + render(); + expect(screen.getByText(/^1:(2\d|30)$/)).toBeTruthy(); + vi.useRealTimers(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/TTSPlayerSheet.test.tsx b/apps/readest-app/src/__tests__/components/tts/TTSPlayerSheet.test.tsx new file mode 100644 index 00000000..cf2d293e --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/TTSPlayerSheet.test.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string, opts?: Record) => + opts ? Object.entries(opts).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) : key, +})); + +vi.mock('@/hooks/useResponsiveSize', () => ({ + useResponsiveSize: (size: number) => size, + useDefaultIconSize: () => 24, +})); + +vi.mock('@/components/Dialog', () => ({ + default: ({ + isOpen, + header, + children, + }: { + isOpen: boolean; + header?: React.ReactNode; + children: React.ReactNode; + }) => + isOpen ? ( +
+ {header} + {children} +
+ ) : null, +})); + +const envConfig = {}; +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig, appService: { hasHaptics: false } }), +})); + +const viewSettings: Record = {}; +const setViewSettings = vi.fn(); +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + getViewSettings: () => viewSettings, + setViewSettings, + }), +})); + +const settings = { globalViewSettings: { ttsRate: 1.0 } }; +const saveSettings = vi.fn(); +const settingsState = { settings, setSettings: vi.fn(), saveSettings }; +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: Object.assign(() => settingsState, { getState: () => settingsState }), +})); + +const getBookData = vi.fn(); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData }), +})); + +vi.mock('@/store/readerProgressStore', () => ({ + useBookProgress: () => ({ sectionLabel: 'Chapter 5' }), +})); + +import TTSPlayerSheet from '@/app/reader/components/tts/TTSPlayerSheet'; + +const voiceGroups = [ + { + id: 'edge', + name: 'Edge TTS', + voices: [ + { id: 'ava', name: 'Ava', lang: 'en-US', disabled: false }, + { id: 'guy', name: 'Guy', lang: 'en-US', disabled: false }, + ], + }, +]; + +const makeProps = (overrides: Record = {}) => ({ + bookKey: 'b1', + isOpen: true, + ttsLang: 'en', + isPlaying: true, + hasTimeline: true, + timeoutOption: 0, + timeoutTimestamp: 0, + chapterRemainingSec: null as number | null, + onClose: vi.fn(), + onTogglePlay: vi.fn(), + onBackward: vi.fn(), + onForward: vi.fn(), + onSetRate: vi.fn(), + onGetVoices: vi.fn().mockResolvedValue(voiceGroups), + onSetVoice: vi.fn(), + onGetVoiceId: vi.fn().mockReturnValue('ava'), + onSelectTimeout: vi.fn(), + onSeek: vi.fn().mockResolvedValue(undefined), + onGetPlaybackInfo: vi + .fn() + .mockReturnValue({ position: 10, duration: 100, measuredFraction: 0.4 }), + ...overrides, +}); + +describe('TTSPlayerSheet', () => { + beforeEach(() => { + viewSettings['ttsRate'] = 1.0; + viewSettings['isEink'] = false; + getBookData.mockReturnValue({ + book: { title: 'Alice in Wonderland', coverImageUrl: null }, + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('shows title, chapter, scrubber, and transport on the main view', async () => { + render(); + expect(screen.getByText('Alice in Wonderland')).toBeTruthy(); + expect(screen.getByText('Chapter 5')).toBeTruthy(); + expect(screen.getByRole('slider')).toBeTruthy(); + expect(screen.getByLabelText('Previous Paragraph')).toBeTruthy(); + expect(screen.getByLabelText('Next Paragraph')).toBeTruthy(); + // Compact one-row controls: speed / voice / sleep timer buttons. + expect(screen.getByLabelText('Speed')).toBeTruthy(); + expect(screen.getByLabelText('Sleep Timer')).toBeTruthy(); + expect(await screen.findByText('Ava')).toBeTruthy(); // voice button caption + // The main view carries no header label (vertical space). + expect(screen.queryByText('Read Aloud')).toBeNull(); + }); + + test('degrades without a timeline: no scrubber, estimate text instead', () => { + render( + , + ); + expect(screen.queryByRole('slider')).toBeNull(); + expect(screen.getByText(/5:00 left in chapter/)).toBeTruthy(); + }); + + test('transport buttons pass paragraph/sentence semantics', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Previous Paragraph')); + expect(props.onBackward).toHaveBeenCalledWith(false); + fireEvent.click(screen.getByLabelText('Previous Sentence')); + expect(props.onBackward).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText('Next Sentence')); + expect(props.onForward).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText('Next Paragraph')); + expect(props.onForward).toHaveBeenCalledWith(false); + }); + + test('speed button drills into the chips and selecting persists the rate', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Speed')); + fireEvent.click(screen.getByRole('radio', { name: '1.5×' })); + expect(props.onSetRate).toHaveBeenCalledWith(1.5); + expect(viewSettings['ttsRate']).toBe(1.5); + expect(settings.globalViewSettings.ttsRate).toBe(1.5); + expect(saveSettings).toHaveBeenCalled(); + }); + + test('voice button drills into the voice list and selects a voice', async () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Voice')); + fireEvent.click(await screen.findByText('Guy')); + expect(props.onSetVoice).toHaveBeenCalledWith('guy', 'en-US'); + expect(viewSettings['ttsVoice']).toBe('guy'); + }); + + test('timer button drills into the timer list and selects a timeout', async () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Sleep Timer')); + // The translation mock interpolates, so options render as real labels. + fireEvent.click(await screen.findByText('30 minutes')); + expect(props.onSelectTimeout).toHaveBeenCalledWith('b1', 1800); + }); + + test('reopening the sheet returns to the main view', async () => { + const props = makeProps(); + const { rerender } = render(); + fireEvent.click(screen.getByLabelText('Voice')); + expect(await screen.findByText('Guy')).toBeTruthy(); + rerender(); + rerender(); + expect(screen.getByLabelText('Previous Paragraph')).toBeTruthy(); + expect(screen.queryByText('Guy')).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/TTSScrubber.test.tsx b/apps/readest-app/src/__tests__/components/tts/TTSScrubber.test.tsx new file mode 100644 index 00000000..2d2fbd39 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/TTSScrubber.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string, opts?: Record) => + opts ? Object.entries(opts).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) : key, +})); + +import { eventDispatcher } from '@/utils/event'; +import TTSScrubber from '@/app/reader/components/tts/TTSScrubber'; + +const makeGet = (position: number, duration = 100, measuredFraction = 0.4) => + vi.fn().mockReturnValue({ position, duration, measuredFraction }); + +describe('TTSScrubber', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('renders elapsed and remaining labels', () => { + render( + , + ); + expect(screen.getByText('0:10')).toBeTruthy(); + expect(screen.getByText('-1:30')).toBeTruthy(); + }); + + test('paints played and buffered regions into the track gradient', () => { + render( + , + ); + const slider = screen.getByRole('slider') as HTMLInputElement; + expect(slider.style.background).toContain('10%'); + expect(slider.style.background).toContain('40%'); + }); + + test('commits a seek on release and holds the thumb optimistically', () => { + const onSeek = vi.fn().mockReturnValue(new Promise(() => {})); + render( + , + ); + const slider = screen.getByRole('slider') as HTMLInputElement; + fireEvent.change(slider, { target: { value: '50' } }); + expect(onSeek).not.toHaveBeenCalled(); // drag in progress, no seek yet + fireEvent.touchEnd(slider); + expect(onSeek).toHaveBeenCalledWith(50); + expect(slider.value).toBe('50'); + }); + + test('failed seek restores the previous position and toasts', async () => { + const onSeek = vi.fn().mockRejectedValue(new Error('offline')); + const onToast = vi.fn(); + eventDispatcher.on('toast', onToast); + render( + , + ); + const slider = screen.getByRole('slider') as HTMLInputElement; + fireEvent.change(slider, { target: { value: '50' } }); + await act(async () => { + fireEvent.touchEnd(slider); + }); + // The rejection handler dispatches the toast asynchronously. + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ message: 'Failed to seek' }) }), + ); + }); + expect(slider.value).toBe('10'); + eventDispatcher.off('toast', onToast); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/usePlaybackInfo.test.tsx b/apps/readest-app/src/__tests__/components/tts/usePlaybackInfo.test.tsx new file mode 100644 index 00000000..c50b96f4 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/usePlaybackInfo.test.tsx @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; + +import { eventDispatcher } from '@/utils/event'; +import { usePlaybackInfo } from '@/app/reader/components/tts/usePlaybackInfo'; + +const info = (position: number, duration = 100, measuredFraction = 0) => ({ + position, + duration, + measuredFraction, +}); + +describe('usePlaybackInfo', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test('reads immediately and polls every second', () => { + const get = vi.fn().mockReturnValue(info(10, 100, 0.3)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + expect(result.current.ready).toBe(true); + expect(result.current.position).toBe(10); + expect(result.current.total).toBe(100); + expect(result.current.measuredFraction).toBe(0.3); + + get.mockReturnValue(info(12)); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.position).toBe(12); + }); + + test('holds position monotonic against small backward drift, follows big jumps', () => { + const get = vi.fn().mockReturnValue(info(20)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + get.mockReturnValue(info(18.5)); // < 3s drift: estimate refinement + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.position).toBe(20); + + get.mockReturnValue(info(5)); // > 3s jump: deliberate (seek / chapter) + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.position).toBe(5); + }); + + test('quantizes the displayed total: follows only >2% moves', () => { + const get = vi.fn().mockReturnValue(info(10, 100)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + get.mockReturnValue(info(11, 101)); // 1% drift: hold + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.total).toBe(100); + + get.mockReturnValue(info(12, 110)); // 10% move: follow + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.total).toBe(110); + }); + + test('goes stale but keeps last values when the poll returns null', () => { + const get = vi.fn().mockReturnValue(info(10)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + get.mockReturnValue(null); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.stale).toBe(true); + expect(result.current.position).toBe(10); + }); + + test('setRefreshPaused freezes polling while dragging', () => { + const get = vi.fn().mockReturnValue(info(10)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + act(() => { + result.current.setRefreshPaused(true); + }); + get.mockReturnValue(info(30)); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.position).toBe(10); + }); + + test('applySeek lands optimistically, suppresses polls, rollback restores', () => { + const get = vi.fn().mockReturnValue(info(10)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + let rollback: () => void; + act(() => { + rollback = result.current.applySeek(50); + }); + expect(result.current.position).toBe(50); + + get.mockReturnValue(info(11)); // stale server position inside suppression window + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.position).toBe(50); + + act(() => { + rollback!(); + }); + expect(result.current.position).toBe(10); + }); + + test('eink mode refreshes on sentence tts-position events, not on a timer', async () => { + const get = vi.fn().mockReturnValue(info(10)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: true, onGetPlaybackInfo: get }), + ); + get.mockReturnValue(info(15)); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current.position).toBe(10); + + await act(async () => { + await eventDispatcher.dispatch('tts-position', { bookKey: 'b1', kind: 'sentence' }); + }); + expect(result.current.position).toBe(15); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx index b3cb9b09..ee67fed6 100644 --- a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx @@ -57,7 +57,6 @@ const mockViewSettings = { ttsRate: 1, ttsHighlightOptions: { style: 'highlight', color: '#ffff00' }, isEink: false, - showTTSBar: false, ttsMediaMetadata: 'sentence', translationEnabled: false, ttsReadAloudText: 'source', diff --git a/apps/readest-app/src/__tests__/services/constants.test.ts b/apps/readest-app/src/__tests__/services/constants.test.ts index af9b0d95..5335b4dc 100644 --- a/apps/readest-app/src/__tests__/services/constants.test.ts +++ b/apps/readest-app/src/__tests__/services/constants.test.ts @@ -652,7 +652,6 @@ describe('services/constants', () => { expect(DEFAULT_TTS_CONFIG.ttsRate).toBeGreaterThan(0); expect(typeof DEFAULT_TTS_CONFIG.ttsVoice).toBe('string'); expect(typeof DEFAULT_TTS_CONFIG.ttsLocation).toBe('string'); - expect(typeof DEFAULT_TTS_CONFIG.showTTSBar).toBe('boolean'); expect(typeof DEFAULT_TTS_CONFIG.ttsMediaMetadata).toBe('string'); }); diff --git a/apps/readest-app/src/__tests__/services/tts-controller.test.ts b/apps/readest-app/src/__tests__/services/tts-controller.test.ts index 05ec991b..3f56ae97 100644 --- a/apps/readest-app/src/__tests__/services/tts-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-controller.test.ts @@ -67,6 +67,7 @@ vi.mock('foliate-js/tts.js', () => ({ nextMark: vi.fn().mockReturnValue('nextMark'), prevMark: vi.fn().mockReturnValue('prevMark'), setMark: vi.fn().mockReturnValue(new Range()), + getLastRange: vi.fn().mockReturnValue(new Range()), doc: null, }); }), @@ -1319,4 +1320,68 @@ describe('TTSController', () => { expect(handler).toHaveBeenCalledTimes(1); }); }); + + describe('highlight hygiene', () => { + test('section change clears highlights from every live view, not just the primary', async () => { + // Two sections rendered at once (spread / preloaded adjacent view); the + // view has already navigated ahead so the primary is the NEW section. + const mockDoc = { querySelector: vi.fn().mockReturnValue(null) } as unknown as Document; + const overlayers = [ + { remove: vi.fn(), add: vi.fn() }, + { remove: vi.fn(), add: vi.fn() }, + ]; + const twoSectionView = { + renderer: { + primaryIndex: 1, + getContents: vi.fn().mockReturnValue([ + { doc: mockDoc, index: 0, overlayer: overlayers[0] }, + { doc: mockDoc, index: 1, overlayer: overlayers[1] }, + ]), + }, + book: { + sections: [ + { createDocument: vi.fn().mockResolvedValue(mockDoc) }, + { createDocument: vi.fn().mockResolvedValue(mockDoc) }, + ], + }, + language: { isCJK: false }, + tts: null, + getCFI: vi.fn().mockReturnValue('cfi-string'), + resolveCFI: vi.fn().mockReturnValue({ anchor: vi.fn().mockReturnValue(new Range()) }), + } as unknown as FoliateView; + const c = new TTSController(mockAppService, twoSectionView, false); + // Every section entry (start, prev/next, auto-advance) funnels through + // #initTTSForSection; entering a section must scrub the TTS highlight + // from EVERY live view, or the outgoing section's last spoken word + // stays highlighted forever in the preloaded neighbor. + await c.initViewTTS(0); + expect(overlayers[0]!.remove).toHaveBeenCalledWith('tts-highlight'); + expect(overlayers[1]!.remove).toHaveBeenCalledWith('tts-highlight'); + }); + + test('reapplyCurrentHighlight never draws the sentence in word mode while playing', async () => { + await controller.initViewTTS(0); + controller.ttsClient.supportsWordBoundaries = vi + .fn() + .mockReturnValue(true) as unknown as () => boolean; + controller.setHighlightGranularity('word'); + controller.state = 'playing'; + const content = ( + mockView.renderer.getContents() as unknown as { + overlayer: { add: ReturnType }; + }[] + )[0]!; + content.overlayer.add.mockClear(); + + // Between a sentence's mark and its first word boundary a page relocate + // triggers a re-apply; the whole sentence must not flash in. + controller.reapplyCurrentHighlight(); + expect(content.overlayer.add).not.toHaveBeenCalled(); + + // Paused keeps the sentence re-draw (deliberate navigation UX). + controller.state = 'paused'; + controller.reapplyCurrentHighlight(); + expect(content.overlayer.add).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/readest-app/src/__tests__/services/tts-duration.test.ts b/apps/readest-app/src/__tests__/services/tts-duration.test.ts index da966632..d1ed21f9 100644 --- a/apps/readest-app/src/__tests__/services/tts-duration.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-duration.test.ts @@ -95,6 +95,41 @@ describe('estimateSentenceSeconds', () => { }); }); +describe('calibration stability (cumulative history)', () => { + test('a single outlier barely moves a well-established calibration', () => { + const voice = 'stable-voice'; + for (let i = 0; i < 100; i++) { + calibrateVoiceRate(voice, 'a'.repeat(50), 5); // 10 cps, 500s of history + } + const before = estimateSentenceSeconds('z'.repeat(100), 'en', voice); + calibrateVoiceRate(voice, 'b'.repeat(90), 3); // 30 cps outlier sentence + const after = estimateSentenceSeconds('z'.repeat(100), 'en', voice); + // The whole point of cumulative smoothing: one weird sentence must not + // re-price the chapter. (The old EMA moved this by ~28%.) + expect(Math.abs(after - before) / before).toBeLessThan(0.02); + }); + + test('history is duration-weighted, not sample-weighted', () => { + const voice = 'weighted-voice'; + calibrateVoiceRate(voice, 'a'.repeat(40), 2); // 20 cps for 2s + calibrateVoiceRate(voice, 'b'.repeat(90), 9); // 10 cps for 9s + // Cumulative ratio: 130 chars / 11 s, NOT the sample mean of 20 and 10. + const est = estimateSentenceSeconds('c'.repeat(100), 'en', voice); + expect(est).toBeCloseTo(100 / (130 / 11), 1); + }); + + test('legacy {cps,n} storage format is migrated as a prior', async () => { + localStorage.setItem( + 'readest-tts-voice-cps', + JSON.stringify({ 'legacy-voice': { cps: 20, n: 5 } }), + ); + vi.resetModules(); + const fresh = await import('@/services/tts/ttsDuration'); + const est = fresh.estimateSentenceSeconds('d'.repeat(100), 'en', 'legacy-voice'); + expect(est).toBeCloseTo(5, 1); // 100 chars at the legacy 20 cps + }); +}); + describe('persistence', () => { test('per-voice calibration survives a module reload via localStorage', async () => { calibrateVoiceRate('persisted-voice', 'a'.repeat(50), 5); diff --git a/apps/readest-app/src/__tests__/utils/time-format.test.ts b/apps/readest-app/src/__tests__/utils/time-format.test.ts index 9643dbc1..3c6ec155 100644 --- a/apps/readest-app/src/__tests__/utils/time-format.test.ts +++ b/apps/readest-app/src/__tests__/utils/time-format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { formatPlaybackTime } from '@/utils/time'; +import { formatCountdown, formatPlaybackTime } from '@/utils/time'; describe('formatPlaybackTime', () => { test('formats minutes and seconds by default', () => { @@ -31,3 +31,16 @@ describe('formatPlaybackTime', () => { expect(formatPlaybackTime(59.9)).toBe('0:59'); }); }); + +describe('formatCountdown', () => { + test('formats total minutes and seconds', () => { + expect(formatCountdown(90_000)).toBe('1:30'); + expect(formatCountdown(5_000)).toBe('0:05'); + // Total minutes, never rolled into hours: 90 minutes reads 90:00. + expect(formatCountdown(90 * 60_000)).toBe('90:00'); + }); + + test('clamps negative remaining time to zero', () => { + expect(formatCountdown(-1_000)).toBe('0:00'); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index f09c305c..fb749eb1 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -85,6 +85,7 @@ import Spinner from '@/components/Spinner'; import KOSyncConflictResolver from './KOSyncResolver'; import ImageViewer from './ImageViewer'; import TableViewer from './TableViewer'; +import { TTS_MINI_PLAYER_CLEARANCE } from './tts/TTSMiniPlayer'; declare global { interface Window { @@ -784,11 +785,12 @@ const FoliateViewer: React.FC<{ const showTopHeader = viewSettings.showHeader && !viewSettings.vertical; const showBottomFooter = viewSettings.showFooter && !viewSettings.vertical; const moreTopInset = showTopHeader ? Math.max(0, 16 - insets.top) : 0; - const ttsBarHeight = - viewState?.ttsEnabled && viewSettings.showTTSBar ? 52 + gridInsets.bottom * 0.33 : 0; + const miniPlayerClearance = viewState?.ttsEnabled + ? TTS_MINI_PLAYER_CLEARANCE + gridInsets.bottom * 0.33 + : 0; const moreBottomInset = showBottomFooter - ? Math.max(0, Math.max(ttsBarHeight, 16) - insets.bottom) - : Math.max(0, ttsBarHeight); + ? Math.max(0, Math.max(miniPlayerClearance, 16) - insets.bottom) + : Math.max(0, miniPlayerClearance); const moreRightInset = showDoubleBorderHeader ? 32 : 0; const moreLeftInset = showDoubleBorderFooter ? 32 : 0; const topMargin = (showTopHeader ? insets.top : viewInsets.top) + moreTopInset; @@ -806,7 +808,9 @@ const FoliateViewer: React.FC<{ const safeBottomPadding = appService?.hasSafeAreaInset ? gridInsets.bottom * 0.33 : 0; const footerBarHeight = safeBottomPadding + viewSettings.marginBottomPx; const scrollTop = headerVisible ? gridInsets.top + viewSettings.marginTopPx : 0; - const scrollBottom = footerVisible ? Math.max(footerBarHeight, ttsBarHeight) : ttsBarHeight; + const scrollBottom = footerVisible + ? Math.max(footerBarHeight, miniPlayerClearance) + : miniPlayerClearance; setScrollMargins({ top: scrollTop, bottom: scrollBottom }); } else { setScrollMargins({ top: 0, bottom: 0 }); @@ -919,7 +923,6 @@ const FoliateViewer: React.FC<{ viewSettings?.doubleBorder, viewSettings?.showHeader, viewSettings?.showFooter, - viewSettings?.showTTSBar, viewSettings?.scrolled, viewSettings?.noContinuousScroll, viewState?.ttsEnabled, diff --git a/apps/readest-app/src/app/reader/components/tts/SpeedChips.tsx b/apps/readest-app/src/app/reader/components/tts/SpeedChips.tsx new file mode 100644 index 00000000..b802fd8f --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/SpeedChips.tsx @@ -0,0 +1,51 @@ +import clsx from 'clsx'; +import { useTranslation } from '@/hooks/useTranslation'; + +export const SPEED_PRESETS = [0.5, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]; + +export const formatRate = (rate: number) => `${parseFloat(rate.toFixed(2))}×`; + +type SpeedChipsProps = { + rate: number; + onSelect: (rate: number) => void; +}; + +// Playback-speed presets as a wrapping chip grid (lives in the sheet's Speed +// sub-view). A persisted off-preset rate (e.g. 1.3 from the old slider or +// the default config) merges in as a selectable chip in sorted position so +// the grid always shows the truth. +const SpeedChips = ({ rate, onSelect }: SpeedChipsProps) => { + const _ = useTranslation(); + const values = SPEED_PRESETS.includes(rate) + ? SPEED_PRESETS + : [...SPEED_PRESETS, rate].sort((a, b) => a - b); + + return ( +
+ {values.map((value) => { + const active = value === rate; + return ( + + ); + })} +
+ ); +}; + +export default SpeedChips; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSBar.tsx b/apps/readest-app/src/app/reader/components/tts/TTSBar.tsx deleted file mode 100644 index 2c0a8fa9..00000000 --- a/apps/readest-app/src/app/reader/components/tts/TTSBar.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import clsx from 'clsx'; -import { - MdPlayArrow, - MdOutlinePause, - MdFastRewind, - MdFastForward, - MdSkipPrevious, - MdSkipNext, -} 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'; - -type TTSBarProps = { - bookKey: string; - isPlaying: boolean; - onTogglePlay: () => void; - onBackward: (byMark: boolean) => void; - onForward: (byMark: boolean) => void; - gridInsets: Insets; -}; - -const TTSBar = ({ - bookKey, - isPlaying, - onTogglePlay, - onBackward, - onForward, - gridInsets, -}: TTSBarProps) => { - const _ = useTranslation(); - const { appService } = useEnv(); - const { hoveredBookKey, setHoveredBookKey } = useReaderStore(); - const iconSize32 = useResponsiveSize(30); - const iconSize48 = useResponsiveSize(36); - - const isVisible = hoveredBookKey !== bookKey; - - return ( -
!appService?.isMobile && setHoveredBookKey('')} - onTouchStart={() => !appService?.isMobile && setHoveredBookKey('')} - > -
- - - - - -
-
- ); -}; - -export default TTSBar; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index 59c3033a..63f904cf 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -1,26 +1,13 @@ import clsx from 'clsx'; -import React, { useState, useRef, useEffect } from 'react'; -import { useEnv } from '@/context/EnvContext'; +import React, { useEffect, useRef, useState } from 'react'; import { useThemeStore } from '@/store/themeStore'; import { useReaderStore } from '@/store/readerStore'; import { useTranslation } from '@/hooks/useTranslation'; -import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { useTTSControl } from '@/app/reader/hooks/useTTSControl'; -import { getPopupPosition, Position } from '@/utils/sel'; import { Insets } from '@/types/misc'; -import { Overlay } from '@/components/Overlay'; -import Popup from '@/components/Popup'; -import TTSPanel from './TTSPanel'; -import TTSIcon from './TTSIcon'; -import TTSBar from './TTSBar'; - -const POPUP_WIDTH = 282; -// The popup is fixed-height and non-scrolling: these must track the panel's -// content or rows get clipped. The taller variant fits the scrubber row that -// only timeline-capable (Edge) playback shows. -const POPUP_HEIGHT = 160; -const POPUP_HEIGHT_WITH_PROGRESS = 184; -const POPUP_PADDING = 10; +import { eventDispatcher } from '@/utils/event'; +import TTSMiniPlayer from './TTSMiniPlayer'; +import TTSPlayerSheet from './TTSPlayerSheet'; interface TTSControlProps { bookKey: string; @@ -29,37 +16,21 @@ interface TTSControlProps { const TTSControl: React.FC = ({ bookKey, gridInsets }) => { const _ = useTranslation(); - const { appService } = useEnv(); const { safeAreaInsets } = useThemeStore(); - const { hoveredBookKey, getViewSettings } = useReaderStore(); + const { getViewSettings } = useReaderStore(); - const viewSettings = getViewSettings(bookKey); - - const [showPanel, setShowPanel] = useState(false); - const [panelPosition, setPanelPosition] = useState(); - const [trianglePosition, setTrianglePosition] = useState(); - - const iconRef = useRef(null); - const hoverTimeoutRef = useRef | null>(null); + const [showPlayerSheet, setShowPlayerSheet] = useState(false); const backButtonTimeoutRef = useRef | null>(null); - const [showIndicatorWithinTimeout, setShowIndicatorWithinTimeout] = useState(true); - const [shouldMountBackButton, setShouldMountBackButton] = useState(false); const [isBackButtonVisible, setIsBackButtonVisible] = useState(false); - const popupPadding = useResponsiveSize(POPUP_PADDING); - const maxWidth = window.innerWidth - 2 * popupPadding; - const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH)); - const popupBaseHeight = useResponsiveSize(POPUP_HEIGHT); - const popupProgressHeight = useResponsiveSize(POPUP_HEIGHT_WITH_PROGRESS); - const tts = useTTSControl({ bookKey, - onRequestHidePanel: () => setShowPanel(false), + onRequestHidePanel: () => setShowPlayerSheet(false), }); - const hasPlaybackInfo = tts.ttsClientsInited && tts.handleSupportsPlaybackInfo(); - const popupHeight = hasPlaybackInfo ? popupProgressHeight : popupBaseHeight; + const isEink = getViewSettings(bookKey)?.isEink ?? false; + const hasTimeline = tts.ttsClientsInited && tts.handleSupportsPlaybackInfo(); useEffect(() => { if (tts.showBackToCurrentTTSLocation) { @@ -80,90 +51,13 @@ const TTSControl: React.FC = ({ bookKey, gridInsets }) => { } }, [tts.showBackToCurrentTTSLocation]); - useEffect(() => { - if (!iconRef.current || !showPanel) return; - const parentElement = iconRef.current.parentElement; - if (!parentElement) return; - - const resizeObserver = new ResizeObserver(() => { - updatePanelPosition(); - }); - resizeObserver.observe(parentElement); - return () => { - resizeObserver.disconnect(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showPanel]); - - useEffect(() => { - if (hoveredBookKey) { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - const showTimeout = setTimeout(() => { - setShowIndicatorWithinTimeout(true); - }, 100); - hoverTimeoutRef.current = showTimeout; - } else { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - const hideTimeout = setTimeout(() => { - setShowIndicatorWithinTimeout(false); - }, 5000); - hoverTimeoutRef.current = hideTimeout; - } - - return () => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } - }; - }, [hoveredBookKey]); - - useEffect(() => { - if (tts.showTTSBar) { - setShowPanel(false); - } - }, [tts.showTTSBar]); - - const updatePanelPosition = () => { - if (iconRef.current) { - const rect = iconRef.current.getBoundingClientRect(); - const parentRect = - iconRef.current.parentElement?.getBoundingClientRect() || - document.documentElement.getBoundingClientRect(); - - const trianglePos = { - dir: 'up', - point: { x: rect.left + rect.width / 2 - parentRect.left, y: rect.top - 12 }, - } as Position; - - const popupPos = getPopupPosition( - trianglePos, - parentRect, - popupWidth, - popupHeight, - popupPadding, - ); - - setPanelPosition(popupPos); - setTrianglePosition(trianglePos); - } + const handleExpand = () => { + tts.refreshTtsLang(); + setShowPlayerSheet(true); }; - const togglePopup = () => { - updatePanelPosition(); - if (!showPanel && tts.isTTSActive) { - tts.refreshTtsLang(); - } - setShowPanel((prev) => !prev); - }; - - const handleDismissPopup = () => { - setShowPanel(false); + const handleStop = () => { + eventDispatcher.dispatch('tts-stop', { bookKey }); }; return ( @@ -191,67 +85,45 @@ const TTSControl: React.FC = ({ bookKey, gridInsets }) => {
)} - {showPanel && } - {(showPanel || (tts.showIndicator && showIndicatorWithinTimeout)) && ( -
- -
- )} - {showPanel && panelPosition && trianglePosition && tts.ttsClientsInited && ( - - - - )} - {tts.showIndicator && tts.showTTSBar && tts.ttsClientsInited && ( - + )} + {tts.ttsClientsInited && showPlayerSheet && ( + setShowPlayerSheet(false)} + onTogglePlay={tts.handleTogglePlay} + onBackward={tts.handleBackward} + onForward={tts.handleForward} + onSetRate={tts.handleSetRate} + onGetVoices={tts.handleGetVoices} + onSetVoice={tts.handleSetVoice} + onGetVoiceId={tts.handleGetVoiceId} + onSelectTimeout={tts.handleSelectTimeout} + onSeek={tts.handleSeekTo} + onGetPlaybackInfo={tts.handleGetPlaybackInfo} /> )} diff --git a/apps/readest-app/src/app/reader/components/tts/TTSIcon.tsx b/apps/readest-app/src/app/reader/components/tts/TTSIcon.tsx deleted file mode 100644 index 867bd274..00000000 --- a/apps/readest-app/src/app/reader/components/tts/TTSIcon.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import clsx from 'clsx'; -import React from 'react'; - -type TTSIconProps = { - isPlaying: boolean; - ttsInited: boolean; - onClick: () => void; -}; - -const TTSIcon: React.FC = ({ isPlaying, ttsInited, onClick }) => { - const bars = [1, 2, 3, 4]; - - return ( - - ); -}; - -export default TTSIcon; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSMiniPlayer.tsx b/apps/readest-app/src/app/reader/components/tts/TTSMiniPlayer.tsx new file mode 100644 index 00000000..b88f28a2 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/TTSMiniPlayer.tsx @@ -0,0 +1,198 @@ +import clsx from 'clsx'; +import { + MdClose, + MdPauseCircleFilled, + MdPlayCircleFilled, + MdSkipNext, + MdSkipPrevious, +} from 'react-icons/md'; +import { Insets } from '@/types/misc'; +import { useEnv } from '@/context/EnvContext'; +import { useReaderStore } from '@/store/readerStore'; +import { useBookProgress } from '@/store/readerProgressStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { useTranslation } from '@/hooks/useTranslation'; +import { formatPlaybackTime } from '@/utils/time'; +import { TTSPlaybackInfo, usePlaybackInfo } from './usePlaybackInfo'; +import { useCountdownLabel } from './useCountdownLabel'; + +// Reader text reserves this much bottom clearance while a session is active +// (card height + bottom gap); FoliateViewer consumes it via applyMarginAndGap. +export const TTS_MINI_PLAYER_CLEARANCE = 64; + +type TTSMiniPlayerProps = { + bookKey: string; + isPlaying: boolean; + isEink: boolean; + hasTimeline: boolean; + timeoutTimestamp: number; + chapterRemainingSec: number | null; + gridInsets: Insets; + onTogglePlay: () => void; + onBackward: (byMark: boolean) => void; + onForward: (byMark: boolean) => void; + onStop: () => void; + onExpand: () => void; + onGetPlaybackInfo: () => TTSPlaybackInfo | null; +}; + +// Persistent mini-player shown while a TTS session is active: passive +// progress line with buffer-ahead fill on the card's bottom edge, book info +// (tap to expand the full player sheet), sentence transport, and stop. +const TTSMiniPlayer = ({ + bookKey, + isPlaying, + isEink, + hasTimeline, + timeoutTimestamp, + chapterRemainingSec, + gridInsets, + onTogglePlay, + onBackward, + onForward, + onStop, + onExpand, + onGetPlaybackInfo, +}: TTSMiniPlayerProps) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { hoveredBookKey, setHoveredBookKey } = useReaderStore(); + const { getBookData } = useBookDataStore(); + const progress = useBookProgress(bookKey); + const playback = usePlaybackInfo({ bookKey, isEink, onGetPlaybackInfo }); + const timerLabel = useCountdownLabel(timeoutTimestamp); + const iconSize20 = useResponsiveSize(20); + const iconSize28 = useResponsiveSize(28); + const iconSize40 = useResponsiveSize(40); + + const isVisible = hoveredBookKey !== bookKey; + const book = getBookData(bookKey)?.book; + const sectionLabel = progress?.sectionLabel; + + const { ready, position, total, measuredFraction } = playback; + const forceHours = total >= 3600; + const playedPct = ready && total > 0 ? Math.min((position / total) * 100, 100) : 0; + const bufferedPct = ready ? Math.max(playedPct, Math.min(measuredFraction, 1) * 100) : 0; + const timeLabel = + hasTimeline && ready + ? `${formatPlaybackTime(position, forceHours)} · -${formatPlaybackTime( + Math.max(total - position, 0), + forceHours, + )}` + : chapterRemainingSec !== null + ? _('{{time}} left in chapter', { time: formatPlaybackTime(chapterRemainingSec) }) + : ''; + + return ( +
!appService?.isMobile && setHoveredBookKey('')} + onTouchStart={() => !appService?.isMobile && setHoveredBookKey('')} + > +
+ {hasTimeline && ( + // E-ink has no legible grey tints: delineate the track with a crisp + // 1px hairline, drop the buffer fill, and paint progress solid. + + ); +}; + +export default TTSMiniPlayer; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx deleted file mode 100644 index 1ad1577a..00000000 --- a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import clsx from 'clsx'; -import { useState, ChangeEvent, useEffect, useRef, useCallback } from 'react'; -import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md'; -import { TbChevronCompactDown, TbChevronCompactUp } from 'react-icons/tb'; -import { RiVoiceAiFill } from 'react-icons/ri'; -import { MdCheck } from 'react-icons/md'; -import { TTSVoicesGroup } from '@/services/tts'; -import { useEnv } from '@/context/EnvContext'; -import { useReaderStore } from '@/store/readerStore'; -import { TranslationFunc, useTranslation } from '@/hooks/useTranslation'; -import { useSettingsStore } from '@/store/settingsStore'; -import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize'; -import { getLanguageName } from '@/utils/lang'; -import { formatPlaybackTime } from '@/utils/time'; -import { eventDispatcher } from '@/utils/event'; - -type TTSPlaybackInfo = { - position: number; - duration: number; - measuredFraction: number; -}; - -type TTSPanelProps = { - bookKey: string; - ttsLang: string; - isPlaying: boolean; - timeoutOption: number; - timeoutTimestamp: number; - onTogglePlay: () => void; - onBackward: () => void; - onForward: () => void; - onSetRate: (rate: number) => void; - onGetVoices: (lang: string) => Promise; - onSetVoice: (voice: string, lang: string) => void; - onGetVoiceId: () => string; - onSelectTimeout: (bookKey: string, value: number) => void; - onToogleTTSBar: () => void; - onSeek: (seconds: number) => Promise; - onGetPlaybackInfo: () => TTSPlaybackInfo | null; - hasTimeline: boolean; -}; - -// Suppress poll updates briefly after a seek so the optimistic thumb never -// snaps back while the cold-seek fetch completes. -const SEEK_SUPPRESS_MS = 2000; -// Small backward drifts come from estimate refinement, not playback — hold the -// displayed position monotonic; larger jumps are deliberate (seek, chapter). -const MONOTONIC_SLACK_SEC = 3; - -// Chapter progress row: elapsed / thin scrubber / total. Lives between the -// rate slider and the transport cluster; the thin range-xs track plus flanking -// time labels keep it visually distinct from the chunky tick-labeled rate -// slider one block up (misgrabbing THAT persists a global setting). -const TTSProgressRow = ({ - bookKey, - isEink, - onSeek, - onGetPlaybackInfo, -}: { - bookKey: string; - isEink: boolean; - onSeek: (seconds: number) => Promise; - onGetPlaybackInfo: () => TTSPlaybackInfo | null; -}) => { - const _ = useTranslation(); - const [info, setInfo] = useState(null); - const [stale, setStale] = useState(true); - const [displayTotal, setDisplayTotal] = useState(null); - const [dragValue, setDragValue] = useState(null); - const dragValueRef = useRef(null); - const suppressUntilRef = useRef(0); - const lastPositionRef = useRef(0); - const keyboardCommitRef = useRef | null>(null); - - const refresh = useCallback(() => { - if (dragValueRef.current !== null) return; - if (Date.now() < suppressUntilRef.current) return; - const next = onGetPlaybackInfo(); - if (!next) { - // Keep the last-known values rendered (disabled) across chapter - // transitions and while the lazy timeline builds — no row blink. - setStale(true); - return; - } - let position = next.position; - if ( - position < lastPositionRef.current && - lastPositionRef.current - position < MONOTONIC_SLACK_SEC - ) { - position = lastPositionRef.current; - } - lastPositionRef.current = position; - setInfo({ ...next, position }); - setStale(false); - // Quantize the displayed total: only follow estimate drift when it moves - // by more than 2%, so the chapter length reads stable, not twitchy. - setDisplayTotal((prev) => - prev === null || Math.abs(next.duration - prev) / prev > 0.02 ? next.duration : prev, - ); - }, [onGetPlaybackInfo]); - - useEffect(() => { - refresh(); - if (isEink) { - // No 1s repaints on e-ink: follow sentence-level position events only. - const handler = (event: CustomEvent) => { - const detail = event.detail as { bookKey?: string; kind?: string }; - if (detail.bookKey === bookKey && detail.kind === 'sentence') refresh(); - }; - eventDispatcher.on('tts-position', handler); - return () => eventDispatcher.off('tts-position', handler); - } - const interval = setInterval(refresh, 1000); - return () => clearInterval(interval); - }, [refresh, isEink, bookKey]); - - const commitSeek = (seconds: number) => { - dragValueRef.current = null; - setDragValue(null); - // Optimistic thumb: land on the target immediately and hold it there - // while the seek resolves; never snap back. - const previous = lastPositionRef.current; - suppressUntilRef.current = Date.now() + SEEK_SUPPRESS_MS; - lastPositionRef.current = seconds; - setInfo((prev) => (prev ? { ...prev, position: seconds } : prev)); - onSeek(seconds).catch(() => { - // A silent snap-back is the exact violation the optimistic thumb - // prevents — restore visibly and say why. - suppressUntilRef.current = 0; - lastPositionRef.current = previous; - setInfo((prev) => (prev ? { ...prev, position: previous } : prev)); - eventDispatcher.dispatch('toast', { message: _('Failed to seek'), type: 'error' }); - }); - }; - - const handleChange = (e: ChangeEvent) => { - // React fires range onChange continuously during a drag; track the value - // and commit only on pointer/key release. - const value = parseFloat(e.target.value); - dragValueRef.current = value; - setDragValue(value); - }; - - const handlePointerCommit = () => { - if (dragValueRef.current !== null) commitSeek(dragValueRef.current); - }; - - const handleKeyUp = () => { - // Holding an arrow key must not fire a network seek per press. - if (keyboardCommitRef.current) clearTimeout(keyboardCommitRef.current); - keyboardCommitRef.current = setTimeout(() => { - if (dragValueRef.current !== null) commitSeek(dragValueRef.current); - }, 500); - }; - - const total = displayTotal ?? info?.duration ?? 0; - const position = Math.min(dragValue ?? info?.position ?? 0, total); - const ready = info !== null && total > 0; - const forceHours = total >= 3600; - const step = Math.max(5, Math.round(total / 100)) || 5; - const elapsedLabel = ready ? formatPlaybackTime(position, forceHours) : '--:--'; - const remainingLabel = ready - ? `-${formatPlaybackTime(Math.max(total - position, 0), forceHours)}` - : '--:--'; - - return ( -
- {elapsedLabel} - {/* Plain native range, matching the footer's Jump to Location slider: - thin track, small thumb, visually unmistakable for the chunky rate - slider above. */} - - {remainingLabel} -
- ); -}; - -const getTTSTimeoutOptions = (_: TranslationFunc) => { - return [ - { - label: _('No Timeout'), - value: 0, - }, - { - label: _('{{value}} minute', { value: 1 }), - value: 60, - }, - { - label: _('{{value}} minutes', { value: 3 }), - value: 180, - }, - { - label: _('{{value}} minutes', { value: 5 }), - value: 300, - }, - { - label: _('{{value}} minutes', { value: 10 }), - value: 600, - }, - { - label: _('{{value}} minutes', { value: 20 }), - value: 1200, - }, - { - label: _('{{value}} minutes', { value: 30 }), - value: 1800, - }, - { - label: _('{{value}} minutes', { value: 45 }), - value: 2700, - }, - { - label: _('{{value}} hour', { value: 1 }), - value: 3600, - }, - { - label: _('{{value}} hours', { value: 2 }), - value: 7200, - }, - { - label: _('{{value}} hours', { value: 3 }), - value: 10800, - }, - { - label: _('{{value}} hours', { value: 4 }), - value: 14400, - }, - { - label: _('{{value}} hours', { value: 6 }), - value: 21600, - }, - { - label: _('{{value}} hours', { value: 8 }), - value: 28800, - }, - ]; -}; - -const getCountdownTime = (timeout: number) => { - const now = Date.now(); - if (timeout > now) { - const remainingTime = Math.floor((timeout - now) / 1000); - const minutes = Math.floor(remainingTime / 3600) * 60 + Math.floor((remainingTime % 3600) / 60); - const seconds = remainingTime % 60; - return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; - } - return ''; -}; - -const TTSPanel = ({ - bookKey, - ttsLang, - isPlaying, - timeoutOption, - timeoutTimestamp, - onTogglePlay, - onBackward, - onForward, - onSetRate, - onGetVoices, - onSetVoice, - onGetVoiceId, - onSelectTimeout, - onToogleTTSBar, - onSeek, - onGetPlaybackInfo, - hasTimeline, -}: TTSPanelProps) => { - const _ = useTranslation(); - const { envConfig } = useEnv(); - const { getViewSettings, setViewSettings } = useReaderStore(); - const { settings, setSettings, saveSettings } = useSettingsStore(); - const viewSettings = getViewSettings(bookKey); - - const [voiceGroups, setVoiceGroups] = useState([]); - const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0); - const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? ''); - - const [timeoutCountdown, setTimeoutCountdown] = useState(() => { - return getCountdownTime(timeoutTimestamp); - }); - - const defaultIconSize = useDefaultIconSize(); - const iconSize32 = useResponsiveSize(32); - const iconSize48 = useResponsiveSize(48); - - const handleSetRate = (e: ChangeEvent) => { - let newRate = parseFloat(e.target.value); - newRate = Math.max(0.2, Math.min(3.0, newRate)); - setRate(newRate); - onSetRate(newRate); - const viewSettings = getViewSettings(bookKey)!; - viewSettings.ttsRate = newRate; - settings.globalViewSettings.ttsRate = newRate; - setViewSettings(bookKey, viewSettings); - setSettings(settings); - saveSettings(envConfig, settings); - }; - - const handleSelectVoice = (voice: string, lang: string) => { - onSetVoice(voice, lang); - setSelectedVoice(voice); - const viewSettings = getViewSettings(bookKey)!; - viewSettings.ttsVoice = voice; - setViewSettings(bookKey, viewSettings); - }; - - const updateTimeout = (timeout: number) => { - const now = Date.now(); - if (timeout > 0 && timeout < now) { - onSelectTimeout(bookKey, 0); - setTimeoutCountdown(''); - } else if (timeout > 0) { - setTimeoutCountdown(getCountdownTime(timeout)); - } - }; - - useEffect(() => { - setTimeout(() => { - updateTimeout(timeoutTimestamp); - }, 1000); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timeoutTimestamp, timeoutCountdown]); - - useEffect(() => { - const voiceId = onGetVoiceId(); - setSelectedVoice(voiceId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const fetchVoices = async () => { - const voiceGroups = await onGetVoices(ttsLang); - const voicesCount = voiceGroups.reduce((acc, group) => acc + group.voices.length, 0); - if (!voiceGroups || voicesCount === 0) { - console.warn('No voices found for TTSPanel'); - setVoiceGroups([ - { - id: 'no-voices', - name: _('Voices for {{lang}}', { lang: getLanguageName(ttsLang) }), - voices: [], - }, - ]); - } else { - setVoiceGroups(voiceGroups); - } - }; - fetchVoices(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ttsLang]); - - const timeoutOptions = getTTSTimeoutOptions(_); - - return ( -
-
- -
- | - | - | - | - | - | - | -
-
- {_('Slow')} - - 1.0 - 1.5 - 2.0 - - {_('Fast')} -
-
- {hasTimeline && ( - - )} -
- - - -
- -
    - {timeoutOptions.map((option, index) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -
  • onSelectTimeout(bookKey, option.value)} - > -
    - - {timeoutOption === option.value && } - - {option.label} -
    -
  • - ))} -
-
- -
- -
    - {voiceGroups.map((voiceGroup, index) => { - return ( -
    -
    - - - {_('{{engine}}: {{count}} voices', { - engine: _(voiceGroup.name), - count: voiceGroup.voices.length, - })} - -
    - {voiceGroup.voices.map((voice, voiceIndex) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -
  • !voice.disabled && handleSelectVoice(voice.id, voice.lang)} - > -
    - - {selectedVoice === voice.id && } - - - {_(voice.name)} - -
    -
  • - ))} -
    - ); - })} -
-
-
-
- -
-
- ); -}; - -export default TTSPanel; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSPlayerSheet.tsx b/apps/readest-app/src/app/reader/components/tts/TTSPlayerSheet.tsx new file mode 100644 index 00000000..14af68a3 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/TTSPlayerSheet.tsx @@ -0,0 +1,394 @@ +import clsx from 'clsx'; +import { useEffect, useState } from 'react'; +import { + MdAlarm, + MdArrowBackIosNew, + MdCheck, + MdFastForward, + MdFastRewind, + MdOutlinePause, + MdPlayArrow, + MdSkipNext, + MdSkipPrevious, +} from 'react-icons/md'; +import { RiVoiceAiFill } from 'react-icons/ri'; +import { TTSVoicesGroup } from '@/services/tts'; +import { useEnv } from '@/context/EnvContext'; +import { useReaderStore } from '@/store/readerStore'; +import { useBookProgress } from '@/store/readerProgressStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { TranslationFunc, useTranslation } from '@/hooks/useTranslation'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { getLanguageName } from '@/utils/lang'; +import { formatPlaybackTime } from '@/utils/time'; +import Dialog from '@/components/Dialog'; +import { TTSPlaybackInfo } from './usePlaybackInfo'; +import { useCountdownLabel } from './useCountdownLabel'; +import TTSScrubber from './TTSScrubber'; +import SpeedChips, { formatRate } from './SpeedChips'; + +type SheetView = 'main' | 'speed' | 'voice' | 'timer'; + +const getTTSTimeoutOptions = (_: TranslationFunc) => { + return [ + { label: _('No Timeout'), value: 0 }, + { label: _('{{value}} minute', { value: 1 }), value: 60 }, + { label: _('{{value}} minutes', { value: 3 }), value: 180 }, + { label: _('{{value}} minutes', { value: 5 }), value: 300 }, + { label: _('{{value}} minutes', { value: 10 }), value: 600 }, + { label: _('{{value}} minutes', { value: 20 }), value: 1200 }, + { label: _('{{value}} minutes', { value: 30 }), value: 1800 }, + { label: _('{{value}} minutes', { value: 45 }), value: 2700 }, + { label: _('{{value}} hour', { value: 1 }), value: 3600 }, + { label: _('{{value}} hours', { value: 2 }), value: 7200 }, + { label: _('{{value}} hours', { value: 3 }), value: 10800 }, + { label: _('{{value}} hours', { value: 4 }), value: 14400 }, + { label: _('{{value}} hours', { value: 6 }), value: 21600 }, + { label: _('{{value}} hours', { value: 8 }), value: 28800 }, + ]; +}; + +type TTSPlayerSheetProps = { + bookKey: string; + isOpen: boolean; + ttsLang: string; + isPlaying: boolean; + hasTimeline: boolean; + timeoutOption: number; + timeoutTimestamp: number; + chapterRemainingSec: number | null; + onClose: () => void; + onTogglePlay: () => void; + onBackward: (byMark: boolean) => void; + onForward: (byMark: boolean) => void; + onSetRate: (rate: number) => void; + onGetVoices: (lang: string) => Promise; + onSetVoice: (voice: string, lang: string) => void; + onGetVoiceId: () => string; + onSelectTimeout: (bookKey: string, value: number) => void; + onSeek: (seconds: number) => Promise; + onGetPlaybackInfo: () => TTSPlaybackInfo | null; +}; + +// Full player sheet: cover, chapter, scrubber, transport, and one compact +// row of speed / voice / sleep-timer buttons that drill into in-sheet +// sub-views (dropdowns clip inside the dialog's scroll container). +const TTSPlayerSheet = ({ + bookKey, + isOpen, + ttsLang, + isPlaying, + hasTimeline, + timeoutOption, + timeoutTimestamp, + chapterRemainingSec, + onClose, + onTogglePlay, + onBackward, + onForward, + onSetRate, + onGetVoices, + onSetVoice, + onGetVoiceId, + onSelectTimeout, + onSeek, + onGetPlaybackInfo, +}: TTSPlayerSheetProps) => { + const _ = useTranslation(); + const { envConfig } = useEnv(); + const { getViewSettings, setViewSettings } = useReaderStore(); + const { getBookData } = useBookDataStore(); + const progress = useBookProgress(bookKey); + const viewSettings = getViewSettings(bookKey); + + const [view, setView] = useState('main'); + const [voiceGroups, setVoiceGroups] = useState([]); + const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0); + const [selectedVoice, setSelectedVoice] = useState(''); + const timerLabel = useCountdownLabel(timeoutTimestamp); + const iconSize18 = useResponsiveSize(18); + const iconSize24 = useResponsiveSize(24); + const iconSize32 = useResponsiveSize(32); + + const book = getBookData(bookKey)?.book; + const sectionLabel = progress?.sectionLabel; + const isEink = viewSettings?.isEink ?? false; + + // Fresh open: land on the main view with current rate/voice. + useEffect(() => { + if (!isOpen) return; + setView('main'); + setRate(getViewSettings(bookKey)?.ttsRate ?? 1.0); + setSelectedVoice(onGetVoiceId()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + const fetchVoices = async () => { + const groups = await onGetVoices(ttsLang); + const voicesCount = groups.reduce((acc, group) => acc + group.voices.length, 0); + if (!groups || voicesCount === 0) { + setVoiceGroups([ + { + id: 'no-voices', + name: _('Voices for {{lang}}', { lang: getLanguageName(ttsLang) }), + voices: [], + }, + ]); + } else { + setVoiceGroups(groups); + } + }; + fetchVoices(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, ttsLang]); + + const handleSelectRate = (value: number) => { + setRate(value); + onSetRate(value); + const vs = getViewSettings(bookKey)!; + vs.ttsRate = value; + setViewSettings(bookKey, vs); + // Read the store fresh at call time: a `settings` captured at render goes + // stale if anything else persisted settings since this sheet mounted. + const { settings, setSettings, saveSettings } = useSettingsStore.getState(); + settings.globalViewSettings.ttsRate = value; + setSettings(settings); + saveSettings(envConfig, settings); + }; + + const handleSelectVoice = (voice: string, lang: string) => { + onSetVoice(voice, lang); + setSelectedVoice(voice); + const vs = getViewSettings(bookKey)!; + vs.ttsVoice = voice; + setViewSettings(bookKey, vs); + setView('main'); + }; + + const handleSelectTimeout = (value: number) => { + onSelectTimeout(bookKey, value); + setView('main'); + }; + + const timeoutOptions = getTTSTimeoutOptions(_); + const currentVoiceName = voiceGroups + .flatMap((group) => group.voices) + .find((voice) => voice.id === selectedVoice)?.name; + // Armed timer shows its live countdown on the button; otherwise the button + // just names itself (the alarm icon already carries the affordance). + const timerCaption = timeoutOption > 0 && timerLabel ? timerLabel : _('Sleep Timer'); + + // The main view carries no header label (the content speaks for itself and + // vertical space is tight); sub-views keep the back button and their title. + const header = + view === 'main' ? ( +
+ ) : ( +
+ +
+ + {view === 'speed' + ? _('Speed') + : view === 'voice' + ? _('Select Voice') + : _('Set Timeout')} + +
+
+ ); + + return ( + + {view === 'main' && ( +
+ {book?.coverImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+ {book?.title ?? ''} + {sectionLabel && ( + {sectionLabel} + )} +
+ {hasTimeline ? ( + + ) : ( + chapterRemainingSec !== null && ( + + {_('{{time}} left in chapter', { time: formatPlaybackTime(chapterRemainingSec) })} + + ) + )} +
+ + + + + +
+
+ + + +
+
+ )} + {view === 'speed' && ( +
+ +
+ )} + {view === 'voice' && ( +
+ {voiceGroups.map((voiceGroup) => ( +
+
+ {_('{{engine}}: {{count}} voices', { + engine: _(voiceGroup.name), + count: voiceGroup.voices.length, + })} +
+ {voiceGroup.voices.map((voice) => ( + + ))} +
+ ))} +
+ )} + {view === 'timer' && ( +
+ {timeoutOptions.map((option) => ( + + ))} +
+ )} +
+ ); +}; + +export default TTSPlayerSheet; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSScrubber.tsx b/apps/readest-app/src/app/reader/components/tts/TTSScrubber.tsx new file mode 100644 index 00000000..852e0eb2 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/TTSScrubber.tsx @@ -0,0 +1,100 @@ +import clsx from 'clsx'; +import { ChangeEvent, useRef, useState } from 'react'; +import { useTranslation } from '@/hooks/useTranslation'; +import { formatPlaybackTime } from '@/utils/time'; +import { eventDispatcher } from '@/utils/event'; +import { TTSPlaybackInfo, usePlaybackInfo } from './usePlaybackInfo'; + +type TTSScrubberProps = { + bookKey: string; + isEink: boolean; + onSeek: (seconds: number) => Promise; + onGetPlaybackInfo: () => TTSPlaybackInfo | null; +}; + +// Interactive chapter scrubber: elapsed / track / -remaining. The track is an +// inline three-stop gradient — played (solid), buffered/prefetched (mid), rest +// (faint) — YouTube-style; `.tts-scrubber` in globals.css blanks the native +// track so the gradient IS the track. +const TTSScrubber = ({ bookKey, isEink, onSeek, onGetPlaybackInfo }: TTSScrubberProps) => { + const _ = useTranslation(); + const playback = usePlaybackInfo({ bookKey, isEink, onGetPlaybackInfo }); + const [dragValue, setDragValue] = useState(null); + const dragValueRef = useRef(null); + const keyboardCommitRef = useRef | null>(null); + + const commitSeek = (seconds: number) => { + dragValueRef.current = null; + setDragValue(null); + playback.setRefreshPaused(false); + const rollback = playback.applySeek(seconds); + onSeek(seconds).catch(() => { + // A silent snap-back is the exact violation the optimistic thumb + // prevents — restore visibly and say why. + rollback(); + eventDispatcher.dispatch('toast', { message: _('Failed to seek'), type: 'error' }); + }); + }; + + const handleChange = (e: ChangeEvent) => { + // React fires range onChange continuously during a drag; track the value + // and commit only on pointer/key release. + const value = parseFloat(e.target.value); + dragValueRef.current = value; + setDragValue(value); + playback.setRefreshPaused(true); + }; + + const handlePointerCommit = () => { + if (dragValueRef.current !== null) commitSeek(dragValueRef.current); + }; + + const handleKeyUp = () => { + // Holding an arrow key must not fire a network seek per press. + if (keyboardCommitRef.current) clearTimeout(keyboardCommitRef.current); + keyboardCommitRef.current = setTimeout(() => { + if (dragValueRef.current !== null) commitSeek(dragValueRef.current); + }, 500); + }; + + const { ready, stale, total, measuredFraction } = playback; + const position = Math.min(dragValue ?? playback.position, total); + const forceHours = total >= 3600; + const step = Math.max(5, Math.round(total / 100)) || 5; + const elapsedLabel = ready ? formatPlaybackTime(position, forceHours) : '--:--'; + const remainingLabel = ready + ? `-${formatPlaybackTime(Math.max(total - position, 0), forceHours)}` + : '--:--'; + const playedPct = ready && total > 0 ? Math.min((position / total) * 100, 100) : 0; + const bufferedPct = ready ? Math.max(playedPct, Math.min(measuredFraction, 1) * 100) : 0; + + return ( +
+ {elapsedLabel} + + {remainingLabel} +
+ ); +}; + +export default TTSScrubber; diff --git a/apps/readest-app/src/app/reader/components/tts/useCountdownLabel.ts b/apps/readest-app/src/app/reader/components/tts/useCountdownLabel.ts new file mode 100644 index 00000000..0041431c --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/useCountdownLabel.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; +import { formatCountdown } from '@/utils/time'; + +// Live M:SS label for an armed sleep timer; empty string when no timer. +export const useCountdownLabel = (timeoutTimestamp: number) => { + const [label, setLabel] = useState(''); + useEffect(() => { + if (!timeoutTimestamp) { + setLabel(''); + return; + } + const tick = () => + setLabel(timeoutTimestamp > Date.now() ? formatCountdown(timeoutTimestamp - Date.now()) : ''); + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [timeoutTimestamp]); + return label; +}; diff --git a/apps/readest-app/src/app/reader/components/tts/usePlaybackInfo.ts b/apps/readest-app/src/app/reader/components/tts/usePlaybackInfo.ts new file mode 100644 index 00000000..1d7a9105 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/usePlaybackInfo.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { eventDispatcher } from '@/utils/event'; + +export type TTSPlaybackInfo = { + position: number; + duration: number; + measuredFraction: number; +}; + +// Suppress poll updates briefly after a seek so the optimistic thumb never +// snaps back while the cold-seek fetch completes. +const SEEK_SUPPRESS_MS = 2000; +// Small backward drifts come from estimate refinement, not playback — hold the +// displayed position monotonic; larger jumps are deliberate (seek, chapter). +const MONOTONIC_SLACK_SEC = 3; + +type UsePlaybackInfoOptions = { + bookKey: string; + isEink: boolean; + onGetPlaybackInfo: () => TTSPlaybackInfo | null; +}; + +export const usePlaybackInfo = ({ bookKey, isEink, onGetPlaybackInfo }: UsePlaybackInfoOptions) => { + const [info, setInfo] = useState(null); + const [stale, setStale] = useState(true); + const [displayTotal, setDisplayTotal] = useState(null); + const pausedRef = useRef(false); + const suppressUntilRef = useRef(0); + const lastPositionRef = useRef(0); + + const refresh = useCallback(() => { + if (pausedRef.current) return; + if (Date.now() < suppressUntilRef.current) return; + const next = onGetPlaybackInfo(); + if (!next) { + // Keep the last-known values rendered (stale) across chapter + // transitions and while the lazy timeline builds — no row blink. + setStale(true); + return; + } + let position = next.position; + if ( + position < lastPositionRef.current && + lastPositionRef.current - position < MONOTONIC_SLACK_SEC + ) { + position = lastPositionRef.current; + } + lastPositionRef.current = position; + setInfo({ ...next, position }); + setStale(false); + // Quantize the displayed total: only follow estimate drift when it moves + // by more than 2%, so the chapter length reads stable, not twitchy. + setDisplayTotal((prev) => + prev === null || Math.abs(next.duration - prev) / prev > 0.02 ? next.duration : prev, + ); + }, [onGetPlaybackInfo]); + + useEffect(() => { + refresh(); + if (isEink) { + // No 1s repaints on e-ink: follow sentence-level position events only. + const handler = (event: CustomEvent) => { + const detail = event.detail as { bookKey?: string; kind?: string }; + if (detail.bookKey === bookKey && detail.kind === 'sentence') refresh(); + }; + eventDispatcher.on('tts-position', handler); + return () => eventDispatcher.off('tts-position', handler); + } + const interval = setInterval(refresh, 1000); + return () => clearInterval(interval); + }, [refresh, isEink, bookKey]); + + const setRefreshPaused = useCallback((paused: boolean) => { + pausedRef.current = paused; + }, []); + + // Optimistic seek: land on the target immediately and hold it while the + // seek resolves. Returns a rollback for the failure path so a silent + // snap-back never happens — the caller restores visibly and says why. + const applySeek = useCallback((seconds: number) => { + const previous = lastPositionRef.current; + suppressUntilRef.current = Date.now() + SEEK_SUPPRESS_MS; + lastPositionRef.current = seconds; + setInfo((prev) => (prev ? { ...prev, position: seconds } : prev)); + return () => { + suppressUntilRef.current = 0; + lastPositionRef.current = previous; + setInfo((prev) => (prev ? { ...prev, position: previous } : prev)); + }; + }, []); + + const total = displayTotal ?? info?.duration ?? 0; + return { + ready: info !== null && total > 0, + stale, + position: info?.position ?? 0, + total, + measuredFraction: info?.measuredFraction ?? 0, + setRefreshPaused, + applySeek, + }; +}; diff --git a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts index 756964b1..529875dc 100644 --- a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts +++ b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts @@ -47,7 +47,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); const [showIndicator, setShowIndicator] = useState(false); - const [showTTSBar, setShowTTSBar] = useState(() => !!getViewSettings(bookKey)?.showTTSBar); const [showBackToCurrentTTSLocation, setShowBackToCurrentTTSLocation] = useState(false); const [timeoutOption, setTimeoutOption] = useState(0); @@ -953,16 +952,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp setTimeoutTimestamp(value > 0 ? Date.now() + value * 1000 : 0); }; - const handleToggleTTSBar = () => { - const viewSettings = getViewSettings(bookKey)!; - viewSettings.showTTSBar = !viewSettings.showTTSBar; - setShowTTSBar(viewSettings.showTTSBar); - if (viewSettings.showTTSBar) { - onRequestHidePanel?.(); - } - setViewSettings(bookKey, viewSettings); - }; - const refreshTtsLang = useCallback(() => { const speakingLang = ttsControllerRef.current?.getSpeakingLang(); if (speakingLang) { @@ -977,7 +966,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp ttsClientsInited, isTTSActive: ttsController !== null, showIndicator, - showTTSBar, showBackToCurrentTTSLocation, timeoutOption, timeoutTimestamp, @@ -993,7 +981,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp handleGetVoices, handleGetVoiceId, handleSelectTimeout, - handleToggleTTSBar, handleBackToCurrentTTSLocation, handleSeekTo, handleGetPlaybackInfo, diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 92fa1924..06bf7f3b 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -407,7 +407,6 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = { ttsRate: 1.3, ttsVoice: '', ttsLocation: '', - showTTSBar: false, ttsHighlightOptions: { style: 'highlight', color: '#808080' }, ttsHighlightGranularity: 'word', ttsMediaMetadata: 'sentence', diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 970aea59..382990ae 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -367,10 +367,16 @@ export class TTSController extends EventTarget { }; } - #clearHighlighter() { - const content = this.#getPrimaryContent(); - const overlayer = content?.overlayer as Overlayer | undefined; - overlayer?.remove(HIGHLIGHT_KEY); + // Clear the TTS highlight from EVERY live view, not just the primary one. + // Preloaded adjacent sections keep their documents (and overlays) alive, so + // a section change or stop that only clears the primary leaves the last + // spoken word highlighted in the neighboring view forever. + #clearAllHighlights() { + if (!this.#attached) return; + const contents = this.view.renderer.getContents() as { overlayer?: Overlayer }[]; + for (const { overlayer } of contents) { + overlayer?.remove(HIGHLIGHT_KEY); + } } updateHighlightOptions(options: TTSHighlightOptions) { @@ -400,6 +406,10 @@ export class TTSController extends EventTarget { return false; } + // Entering a section: drop any highlight left behind in the views that + // are still rendering the outgoing section. + this.#clearAllHighlights(); + this.#ttsSectionIndex = sectionIndex; const currentSection = this.#getPrimaryContent(); @@ -993,6 +1003,18 @@ export class TTSController extends EventTarget { this.#getHighlighter()(this.#lastSpeakWordRange.cloneRange()); return; } + // Word mode during playback: between a sentence's mark and its first word + // boundary there is nothing word-level to re-draw yet, and re-drawing the + // sentence here is exactly the whole-sentence flash word mode suppresses + // at setMark. Draw nothing; the next word boundary paints momentarily. + // Paused/stopped states keep the sentence re-draw (navigation UX). + if ( + this.state === 'playing' && + this.ttsClient.supportsWordBoundaries() && + this.#highlightGranularity === 'word' + ) { + return; + } const range = this.#getTts()?.getLastRange(); if (range) this.#getHighlighter()(range.cloneRange()); } @@ -1127,7 +1149,7 @@ export class TTSController extends EventTarget { async shutdown() { await this.stop(); - this.#clearHighlighter(); + this.#clearAllHighlights(); this.#ttsSectionIndex = -1; this.#sectionTimeline = null; this.#timelineSectionIndex = -1; diff --git a/apps/readest-app/src/services/tts/ttsDuration.ts b/apps/readest-app/src/services/tts/ttsDuration.ts index d0b7d5b6..51a4067a 100644 --- a/apps/readest-app/src/services/tts/ttsDuration.ts +++ b/apps/readest-app/src/services/tts/ttsDuration.ts @@ -4,8 +4,12 @@ // 1. Measured durations (exact, from decoded+trimmed audio; provisional // values derive from word-boundary metadata at fetch time and never // overwrite a measured value). -// 2. Per-voice chars-per-second calibration (EMA over measured sentences, -// persisted in localStorage so a voice starts calibrated next session). +// 2. Per-voice chars-per-second calibration: the cumulative ratio of ALL +// measured chars to ALL measured seconds (persisted in localStorage so a +// voice starts calibrated next session). A running total, not an EMA: +// each new sentence moves the estimate less than the last, so the +// timeline's estimated remainder stabilizes instead of re-pricing on +// every quirky sentence. // 3. Per-script defaults for the cold start. // // Keys normalize away punctuation and case because the spoken text (mark text @@ -17,8 +21,15 @@ import { isCJKLang } from '@/utils/lang'; import { LRUCache } from '@/utils/lru'; const CALIBRATION_STORAGE_KEY = 'readest-tts-voice-cps'; -const EMA_ALPHA = 0.2; const MIN_CALIBRATION_CHARS = 10; +// Cap the accumulated history at one hour of measured audio: past that the +// totals are rescaled in place, giving a very-long-horizon forgetting factor. +// The ratio stays stable sentence-to-sentence (no visible timeline jumps) +// while a genuinely changed voice can still drift the calibration over time. +const MAX_CALIBRATION_SECS = 3600; +// Weight granted to a legacy EMA-format calibration on migration: a useful +// prior, but real history overtakes it within a minute of listening. +const LEGACY_PRIOR_SECS = 30; // Fixed per-utterance overhead floor: even a one-word sentence is not // instantaneous once Edge's attack/release around speech is counted. const MIN_SENTENCE_SEC = 0.3; @@ -27,7 +38,8 @@ const CJK_DEFAULT_CPS = 4.5; const LATIN_DEFAULT_CPS = 15; interface VoiceCalibration { - cps: number; + chars: number; + secs: number; n: number; } @@ -51,10 +63,27 @@ const loadCalibrations = (): Record => { try { const raw = localStorage.getItem(CALIBRATION_STORAGE_KEY); if (raw) { - const parsed = JSON.parse(raw) as Record; + const parsed = JSON.parse(raw) as Record< + string, + Partial & { cps?: number } + >; for (const [voiceId, cal] of Object.entries(parsed)) { - if (typeof cal?.cps === 'number' && Number.isFinite(cal.cps) && cal.cps > 0) { - calibrations[voiceId] = { cps: cal.cps, n: cal.n || 1 }; + if ( + typeof cal?.chars === 'number' && + typeof cal?.secs === 'number' && + Number.isFinite(cal.chars) && + Number.isFinite(cal.secs) && + cal.chars > 0 && + cal.secs > 0 + ) { + calibrations[voiceId] = { chars: cal.chars, secs: cal.secs, n: cal.n || 1 }; + } else if (typeof cal?.cps === 'number' && Number.isFinite(cal.cps) && cal.cps > 0) { + // Legacy EMA format: carry the old rate over as a small prior. + calibrations[voiceId] = { + chars: cal.cps * LEGACY_PRIOR_SECS, + secs: LEGACY_PRIOR_SECS, + n: cal.n || 1, + }; } } } @@ -97,15 +126,19 @@ export const calibrateVoiceRate = (voiceId: string, text: string, seconds: numbe if (!Number.isFinite(seconds) || seconds <= 0) return; const chars = normalizeText(text).length; if (chars < MIN_CALIBRATION_CHARS) return; - const cps = chars / seconds; const all = loadCalibrations(); - const existing = all[voiceId]; - if (existing) { - existing.cps = existing.cps * (1 - EMA_ALPHA) + cps * EMA_ALPHA; - existing.n += 1; - } else { - all[voiceId] = { cps, n: 1 }; + const existing = all[voiceId] ?? { chars: 0, secs: 0, n: 0 }; + existing.chars += chars; + existing.secs += seconds; + existing.n += 1; + // Rescale instead of growing unbounded; the ratio (the estimate) is + // untouched, only future samples' relative weight changes. + if (existing.secs > MAX_CALIBRATION_SECS) { + const scale = MAX_CALIBRATION_SECS / existing.secs; + existing.chars *= scale; + existing.secs = MAX_CALIBRATION_SECS; } + all[voiceId] = existing; saveCalibrations(); }; @@ -118,6 +151,6 @@ export const estimateSentenceSeconds = (text: string, lang: string, voiceId: str const chars = normalizeText(text).length; if (chars === 0) return 0; const calibrated = loadCalibrations()[voiceId]; - const cps = calibrated?.cps ?? defaultCharsPerSecond(lang); + const cps = calibrated ? calibrated.chars / calibrated.secs : defaultCharsPerSecond(lang); return Math.max(MIN_SENTENCE_SEC, chars / cps); }; diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index 806d93f8..52ac50da 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -974,3 +974,39 @@ textarea:focus-within, outline: none !important; box-shadow: none !important; } + +/* TTS player scrubber: the track is painted by an inline three-stop gradient + (played / buffered / rest), so the native appearance must be blanked and + the element itself becomes the 4px track. Thumb inherits currentColor. */ +.tts-scrubber { + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 9999px; +} +.tts-scrubber::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 9999px; + background: currentColor; +} +.tts-scrubber::-moz-range-thumb { + width: 12px; + height: 12px; + border: none; + border-radius: 9999px; + background: currentColor; +} +.tts-scrubber:disabled { + opacity: 0.5; +} + +/* E-ink: the gradient's grey stops wash out, so a crisp 1px border marks the + track's extent while the solid currentColor fill and thumb carry the + position. Slightly taller so the fill stays visible inside the border. */ +[data-eink='true'] .tts-scrubber { + height: 6px; + border: 1px solid currentColor; +} diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index a69396ac..32374b15 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -317,7 +317,6 @@ export interface TTSConfig { ttsRate: number; ttsVoice: string; ttsLocation: string; - showTTSBar: boolean; ttsHighlightOptions: TTSHighlightOptions; ttsHighlightGranularity: TTSHighlightGranularity; ttsMediaMetadata: TTSMediaMetadataMode; diff --git a/apps/readest-app/src/utils/time.ts b/apps/readest-app/src/utils/time.ts index 774f47f1..681b40c7 100644 --- a/apps/readest-app/src/utils/time.ts +++ b/apps/readest-app/src/utils/time.ts @@ -49,3 +49,12 @@ export const formatPlaybackTime = (seconds: number, forceHours = false): string } return `${minutes}:${String(secs).padStart(2, '0')}`; }; + +// Countdown label for TTS sleep-timer chips: total minutes : seconds +// (a 90-minute timer reads 90:00, matching the lock-screen convention). +export const formatCountdown = (msLeft: number): string => { + const total = Math.max(0, Math.floor(msLeft / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; +};