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}`; +};