feat(reader): redesign the TTS control as a mini player with an expandable player sheet (#4996)
* feat(reader): add formatCountdown helper for TTS timer chips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): extract shared TTS playback info hook Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add TTS scrubber with buffer-ahead fill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add TTS speed preset chips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add persistent TTS mini player Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add full TTS player sheet with voice and timer sub-views Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reader): cover player sheet view reset on reopen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): replace TTS icon and popup with mini player and player sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): reserve mini player clearance and retire showTTSBar setting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retire shipped TTS follow-ups from TODOS Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(i18n): translate the TTS player strings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): pin mini player transport LTR and unmount the closed player sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): collapse sheet speed, voice, and timer controls into one row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): stabilize timeline estimates with cumulative voice calibration Replace the per-sentence EMA with the cumulative ratio of all measured chars to all measured seconds per voice, so the estimated remainder converges instead of re-pricing on every quirky sentence. Legacy stored calibrations migrate as a small prior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): hide the mini player while the player sheet is open Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tts): clear stale highlights across sections and stop sentence flash in word mode Entering a section now scrubs the TTS highlight from every live view, not just the primary, so the outgoing section's last word no longer stays lit in the preloaded neighbor. reapplyCurrentHighlight no longer redraws the whole sentence during word-mode playback while awaiting the first word boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(reader): move the mini player progress line to the bottom edge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): make the TTS progress bar and scrubber legible in eink mode Grey tints wash out on e-ink: the mini player track gets a 1px hairline with a solid base-content fill (buffer fill hidden), and the sheet scrubber gets a crisp 1px border marking the track extent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(reader): drop the player sheet header label on the main view Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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": "مؤقت النوم"
|
||||
}
|
||||
|
||||
@@ -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": "স্লিপ টাইমার"
|
||||
}
|
||||
|
||||
@@ -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": "གཉིད་ཀྱི་དུས་ཚོད།"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Χρονοδιακόπτης ύπνου"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "تایمر خواب"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "טיימר שינה"
|
||||
}
|
||||
|
||||
@@ -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": "स्लीप टाइमर"
|
||||
}
|
||||
|
||||
@@ -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ő"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "スリープタイマー"
|
||||
}
|
||||
|
||||
@@ -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": "수면 타이머"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Таймер сна"
|
||||
}
|
||||
|
||||
@@ -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": "නින්ද කාල ගණකය"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "ஸ்லீப் டைமர்"
|
||||
}
|
||||
|
||||
@@ -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": "ตั้งเวลาปิด"
|
||||
}
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
|
||||
@@ -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": "Таймер сну"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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ủ"
|
||||
}
|
||||
|
||||
@@ -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": "定时关闭"
|
||||
}
|
||||
|
||||
@@ -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": "定時關閉"
|
||||
}
|
||||
|
||||
@@ -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(<SpeedChips rate={1.0} onSelect={vi.fn()} />);
|
||||
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(<SpeedChips rate={1.3} onSelect={vi.fn()} />);
|
||||
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(<SpeedChips rate={1.0} onSelect={onSelect} />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: '1.5×' }));
|
||||
expect(onSelect).toHaveBeenCalledWith(1.5);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
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 }) => (
|
||||
<div data-testid='mini-player' onClick={onExpand} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/reader/components/tts/TTSPlayerSheet', () => ({
|
||||
__esModule: true,
|
||||
default: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div data-testid='player-sheet' /> : 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(<TTSControl bookKey='b1' gridInsets={gridInsets} />);
|
||||
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(<TTSControl bookKey='b1' gridInsets={gridInsets} />);
|
||||
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(<TTSControl bookKey='b1' gridInsets={gridInsets} />);
|
||||
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(<TTSControl bookKey='b1' gridInsets={gridInsets} />);
|
||||
expect(screen.getByText('Back to TTS Location')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) =>
|
||||
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<string, unknown> = {}) => ({
|
||||
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(<TTSMiniPlayer {...makeProps()} />);
|
||||
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(<TTSMiniPlayer {...props} />);
|
||||
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(<TTSMiniPlayer {...props} />);
|
||||
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(<TTSMiniPlayer {...props} />);
|
||||
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(<TTSMiniPlayer {...makeProps()} />);
|
||||
expect(screen.getByRole('status').className).toContain('pointer-events-none');
|
||||
});
|
||||
|
||||
test('without a timeline shows the estimated chapter remaining instead', () => {
|
||||
render(
|
||||
<TTSMiniPlayer
|
||||
{...makeProps({
|
||||
hasTimeline: false,
|
||||
chapterRemainingSec: 300,
|
||||
onGetPlaybackInfo: vi.fn().mockReturnValue(null),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(<TTSMiniPlayer {...makeProps({ timeoutTimestamp: Date.now() + 90_000 })} />);
|
||||
expect(screen.getByText(/^1:(2\d|30)$/)).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) =>
|
||||
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 ? (
|
||||
<div role='dialog'>
|
||||
{header}
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const envConfig = {};
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ envConfig, appService: { hasHaptics: false } }),
|
||||
}));
|
||||
|
||||
const viewSettings: Record<string, unknown> = {};
|
||||
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<string, unknown> = {}) => ({
|
||||
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(<TTSPlayerSheet {...makeProps()} />);
|
||||
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(
|
||||
<TTSPlayerSheet
|
||||
{...makeProps({
|
||||
hasTimeline: false,
|
||||
chapterRemainingSec: 300,
|
||||
onGetPlaybackInfo: vi.fn().mockReturnValue(null),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(<TTSPlayerSheet {...props} />);
|
||||
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(<TTSPlayerSheet {...props} />);
|
||||
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(<TTSPlayerSheet {...props} />);
|
||||
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(<TTSPlayerSheet {...props} />);
|
||||
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(<TTSPlayerSheet {...props} />);
|
||||
fireEvent.click(screen.getByLabelText('Voice'));
|
||||
expect(await screen.findByText('Guy')).toBeTruthy();
|
||||
rerender(<TTSPlayerSheet {...props} isOpen={false} />);
|
||||
rerender(<TTSPlayerSheet {...props} isOpen={true} />);
|
||||
expect(screen.getByLabelText('Previous Paragraph')).toBeTruthy();
|
||||
expect(screen.queryByText('Guy')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) =>
|
||||
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(
|
||||
<TTSScrubber bookKey='b1' isEink={false} onSeek={vi.fn()} onGetPlaybackInfo={makeGet(10)} />,
|
||||
);
|
||||
expect(screen.getByText('0:10')).toBeTruthy();
|
||||
expect(screen.getByText('-1:30')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('paints played and buffered regions into the track gradient', () => {
|
||||
render(
|
||||
<TTSScrubber
|
||||
bookKey='b1'
|
||||
isEink={false}
|
||||
onSeek={vi.fn()}
|
||||
onGetPlaybackInfo={makeGet(10, 100, 0.4)}
|
||||
/>,
|
||||
);
|
||||
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<void>(() => {}));
|
||||
render(
|
||||
<TTSScrubber bookKey='b1' isEink={false} onSeek={onSeek} onGetPlaybackInfo={makeGet(10)} />,
|
||||
);
|
||||
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(
|
||||
<TTSScrubber bookKey='b1' isEink={false} onSeek={onSeek} onGetPlaybackInfo={makeGet(10)} />,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,6 @@ const mockViewSettings = {
|
||||
ttsRate: 1,
|
||||
ttsHighlightOptions: { style: 'highlight', color: '#ffff00' },
|
||||
isEink: false,
|
||||
showTTSBar: false,
|
||||
ttsMediaMetadata: 'sentence',
|
||||
translationEnabled: false,
|
||||
ttsReadAloudText: 'source',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ vi.mock('foliate-js/tts.js', () => ({
|
||||
nextMark: vi.fn().mockReturnValue('<speak>nextMark</speak>'),
|
||||
prevMark: vi.fn().mockReturnValue('<speak>prevMark</speak>'),
|
||||
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<typeof vi.fn> };
|
||||
}[]
|
||||
)[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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
role='radiogroup'
|
||||
aria-label={_('Speed')}
|
||||
className='flex w-full flex-wrap justify-center gap-2 px-1 py-1'
|
||||
>
|
||||
{values.map((value) => {
|
||||
const active = value === rate;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type='button'
|
||||
role='radio'
|
||||
aria-checked={active}
|
||||
onClick={() => onSelect(value)}
|
||||
className={clsx(
|
||||
'btn btn-sm h-8 min-h-8 shrink-0 rounded-full border-none px-3 font-normal shadow-none',
|
||||
active ? 'btn-primary' : 'bg-base-100 eink-bordered',
|
||||
)}
|
||||
>
|
||||
{formatRate(value)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeedChips;
|
||||
@@ -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 (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-100 absolute bottom-0 z-40',
|
||||
'inset-x-0 mx-auto flex w-full justify-center sm:w-fit',
|
||||
'transition-opacity duration-300',
|
||||
isVisible ? `pointer-events-auto opacity-100` : `pointer-events-none opacity-0`,
|
||||
)}
|
||||
style={{ paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0 }}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey('')}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey('')}
|
||||
>
|
||||
<div className='text-base-content flex h-[52px] items-center space-x-2 px-2'>
|
||||
<button
|
||||
onClick={onBackward.bind(null, false)}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
title={_('Previous Paragraph')}
|
||||
aria-label={_('Previous Paragraph')}
|
||||
>
|
||||
<MdFastRewind size={iconSize32} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onBackward.bind(null, true)}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
title={_('Previous Sentence')}
|
||||
aria-label={_('Previous Sentence')}
|
||||
>
|
||||
<MdSkipPrevious size={iconSize32} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
title={isPlaying ? _('Pause') : _('Play')}
|
||||
aria-label={isPlaying ? _('Pause') : _('Play')}
|
||||
>
|
||||
{isPlaying ? <MdOutlinePause size={iconSize48} /> : <MdPlayArrow size={iconSize48} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward.bind(null, true)}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
title={_('Next Sentence')}
|
||||
aria-label={_('Next Sentence')}
|
||||
>
|
||||
<MdSkipNext size={iconSize32} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward.bind(null, false)}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
title={_('Next Paragraph')}
|
||||
aria-label={_('Next Paragraph')}
|
||||
>
|
||||
<MdFastForward size={iconSize32} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTSBar;
|
||||
@@ -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<TTSControlProps> = ({ 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<Position>();
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position>();
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [showPlayerSheet, setShowPlayerSheet] = useState(false);
|
||||
const backButtonTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<TTSControlProps> = ({ 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<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
|
||||
{(showPanel || (tts.showIndicator && showIndicatorWithinTimeout)) && (
|
||||
<div
|
||||
ref={iconRef}
|
||||
className={clsx(
|
||||
'absolute h-12 w-12',
|
||||
'transition-transform duration-300',
|
||||
viewSettings?.rtl ? 'left-8' : 'right-6',
|
||||
!appService?.hasSafeAreaInset && 'bottom-[70px] sm:bottom-14',
|
||||
)}
|
||||
style={{
|
||||
bottom: appService?.hasSafeAreaInset
|
||||
? `calc(env(safe-area-inset-bottom, 0px) * ${appService?.isIOSApp ? 0.33 : 1} + ${hoveredBookKey ? 70 : 52}px)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TTSIcon
|
||||
isPlaying={tts.isPlaying}
|
||||
ttsInited={tts.ttsClientsInited}
|
||||
onClick={togglePopup}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPanel && panelPosition && trianglePosition && tts.ttsClientsInited && (
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={panelPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
className='bg-base-200 flex shadow-lg'
|
||||
onDismiss={handleDismissPopup}
|
||||
>
|
||||
<TTSPanel
|
||||
bookKey={bookKey}
|
||||
ttsLang={tts.ttsLang}
|
||||
isPlaying={tts.isPlaying}
|
||||
timeoutOption={tts.timeoutOption}
|
||||
timeoutTimestamp={tts.timeoutTimestamp}
|
||||
onTogglePlay={tts.handleTogglePlay}
|
||||
onBackward={tts.handleBackward}
|
||||
onForward={tts.handleForward}
|
||||
onSetRate={tts.handleSetRate}
|
||||
onGetVoices={tts.handleGetVoices}
|
||||
onSetVoice={tts.handleSetVoice}
|
||||
onGetVoiceId={tts.handleGetVoiceId}
|
||||
onSelectTimeout={tts.handleSelectTimeout}
|
||||
onToogleTTSBar={tts.handleToggleTTSBar}
|
||||
onSeek={tts.handleSeekTo}
|
||||
onGetPlaybackInfo={tts.handleGetPlaybackInfo}
|
||||
hasTimeline={hasPlaybackInfo}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
{tts.showIndicator && tts.showTTSBar && tts.ttsClientsInited && (
|
||||
<TTSBar
|
||||
{/* One surface at a time: the sheet replaces the mini player while open. */}
|
||||
{tts.showIndicator && tts.ttsClientsInited && !showPlayerSheet && (
|
||||
<TTSMiniPlayer
|
||||
bookKey={bookKey}
|
||||
isPlaying={tts.isPlaying}
|
||||
onBackward={tts.handleBackward}
|
||||
onTogglePlay={tts.handleTogglePlay}
|
||||
onForward={tts.handleForward}
|
||||
isEink={isEink}
|
||||
hasTimeline={hasTimeline}
|
||||
timeoutTimestamp={tts.timeoutTimestamp}
|
||||
chapterRemainingSec={tts.chapterRemainingSec}
|
||||
gridInsets={gridInsets}
|
||||
onTogglePlay={tts.handleTogglePlay}
|
||||
onBackward={tts.handleBackward}
|
||||
onForward={tts.handleForward}
|
||||
onStop={handleStop}
|
||||
onExpand={handleExpand}
|
||||
onGetPlaybackInfo={tts.handleGetPlaybackInfo}
|
||||
/>
|
||||
)}
|
||||
{tts.ttsClientsInited && showPlayerSheet && (
|
||||
<TTSPlayerSheet
|
||||
bookKey={bookKey}
|
||||
isOpen={showPlayerSheet}
|
||||
ttsLang={tts.ttsLang}
|
||||
isPlaying={tts.isPlaying}
|
||||
hasTimeline={hasTimeline}
|
||||
timeoutOption={tts.timeoutOption}
|
||||
timeoutTimestamp={tts.timeoutTimestamp}
|
||||
chapterRemainingSec={tts.chapterRemainingSec}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
type TTSIconProps = {
|
||||
isPlaying: boolean;
|
||||
ttsInited: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
|
||||
const bars = [1, 2, 3, 4];
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'relative h-full w-full rounded-full',
|
||||
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
)}
|
||||
style={{
|
||||
maskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
|
||||
WebkitMaskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
|
||||
<div
|
||||
className='absolute -inset-full rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
|
||||
style={{
|
||||
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<style>{`
|
||||
@keyframes moveGradient {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(25%, 25%); }
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
50% { transform: scaleY(0.6); }
|
||||
}
|
||||
`}</style>
|
||||
<div className='flex items-end space-x-1'>
|
||||
{bars.map((bar) => (
|
||||
<div
|
||||
key={bar}
|
||||
className='w-1 rounded-t bg-white'
|
||||
style={{
|
||||
height: '16px',
|
||||
animationName: isPlaying ? 'bounce' : 'none',
|
||||
animationDuration: isPlaying ? `${1 + bar * 0.1}s` : '0s',
|
||||
animationTimingFunction: 'ease-in-out',
|
||||
animationIterationCount: 'infinite',
|
||||
animationDelay: `${bar * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTSIcon;
|
||||
@@ -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 (
|
||||
<div
|
||||
role='status'
|
||||
aria-label={`${_('Reading aloud')}: ${book?.title ?? ''}`}
|
||||
className={clsx(
|
||||
'absolute bottom-2 z-40 inset-x-2 sm:inset-x-0 sm:mx-auto sm:w-full sm:max-w-md',
|
||||
'transition-opacity duration-300',
|
||||
isVisible ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
style={{
|
||||
marginBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
|
||||
}}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey('')}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey('')}
|
||||
>
|
||||
<div className='not-eink:bg-base-300 eink-bordered relative overflow-hidden rounded-2xl shadow-lg'>
|
||||
{hasTimeline && (
|
||||
// E-ink has no legible grey tints: delineate the track with a crisp
|
||||
// 1px hairline, drop the buffer fill, and paint progress solid.
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className={clsx(
|
||||
'audio-track not-eink:bg-neutral-content/15 absolute inset-x-0 bottom-0 h-[3px]',
|
||||
'eink:bg-base-100 eink:border-base-content eink:border-t eink:h-[5px]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='audio-buffered-part bg-base-content/35 eink:hidden absolute inset-y-0 left-0'
|
||||
style={{ width: `${bufferedPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className='audio-played-part not-eink:bg-primary eink:bg-base-content absolute inset-y-0 left-0'
|
||||
style={{ width: `${playedPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='text-base-content flex h-14 items-center gap-1 px-2'>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={onExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onExpand();
|
||||
}}
|
||||
aria-label={_('Open Read Aloud player')}
|
||||
className='flex min-w-0 flex-1 cursor-pointer items-center gap-2'
|
||||
>
|
||||
{book?.coverImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={book.coverImageUrl}
|
||||
alt=''
|
||||
className='h-10 w-10 shrink-0 rounded-lg object-cover'
|
||||
/>
|
||||
) : null}
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='truncate text-sm'>{book?.title ?? ''}</span>
|
||||
{(sectionLabel || timeLabel) && (
|
||||
<span className='text-base-content/70 truncate text-xs tabular-nums'>
|
||||
{[sectionLabel, timeLabel].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{timerLabel && (
|
||||
<span className='shrink-0 text-xs tabular-nums opacity-70'>{timerLabel}</span>
|
||||
)}
|
||||
<div dir='ltr' className='flex shrink-0 items-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
className='shrink-0 rounded-full p-1'
|
||||
aria-label={_('Previous Sentence')}
|
||||
onClick={() => onBackward(true)}
|
||||
>
|
||||
<MdSkipPrevious size={iconSize28} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='shrink-0 rounded-full p-0.5'
|
||||
aria-label={isPlaying ? _('Pause') : _('Play')}
|
||||
onClick={onTogglePlay}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<MdPauseCircleFilled size={iconSize40} />
|
||||
) : (
|
||||
<MdPlayCircleFilled size={iconSize40} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='shrink-0 rounded-full p-1'
|
||||
aria-label={_('Next Sentence')}
|
||||
onClick={() => onForward(true)}
|
||||
>
|
||||
<MdSkipNext size={iconSize28} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='shrink-0 rounded-full p-1'
|
||||
aria-label={_('Stop reading aloud')}
|
||||
onClick={onStop}
|
||||
>
|
||||
<MdClose size={iconSize20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTSMiniPlayer;
|
||||
@@ -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<TTSVoicesGroup[]>;
|
||||
onSetVoice: (voice: string, lang: string) => void;
|
||||
onGetVoiceId: () => string;
|
||||
onSelectTimeout: (bookKey: string, value: number) => void;
|
||||
onToogleTTSBar: () => void;
|
||||
onSeek: (seconds: number) => Promise<void>;
|
||||
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<void>;
|
||||
onGetPlaybackInfo: () => TTSPlaybackInfo | null;
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const [info, setInfo] = useState<TTSPlaybackInfo | null>(null);
|
||||
const [stale, setStale] = useState(true);
|
||||
const [displayTotal, setDisplayTotal] = useState<number | null>(null);
|
||||
const [dragValue, setDragValue] = useState<number | null>(null);
|
||||
const dragValueRef = useRef<number | null>(null);
|
||||
const suppressUntilRef = useRef(0);
|
||||
const lastPositionRef = useRef(0);
|
||||
const keyboardCommitRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
|
||||
// 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 (
|
||||
<div className={clsx('flex w-full items-center gap-2 py-1', stale && 'opacity-60')}>
|
||||
<span className='min-w-9 text-center text-xs tabular-nums'>{elapsedLabel}</span>
|
||||
{/* Plain native range, matching the footer's Jump to Location slider:
|
||||
thin track, small thumb, visually unmistakable for the chunky rate
|
||||
slider above. */}
|
||||
<input
|
||||
className='text-base-content min-w-0 grow'
|
||||
type='range'
|
||||
min={0}
|
||||
max={total || 1}
|
||||
step={step}
|
||||
value={position}
|
||||
disabled={!ready || stale}
|
||||
onChange={handleChange}
|
||||
onPointerUp={handlePointerCommit}
|
||||
onTouchEnd={handlePointerCommit}
|
||||
onKeyUp={handleKeyUp}
|
||||
aria-label={_('Chapter progress')}
|
||||
aria-valuetext={_('{{elapsed}} of {{total}}', {
|
||||
elapsed: elapsedLabel,
|
||||
total: ready ? formatPlaybackTime(total, forceHours) : '--:--',
|
||||
})}
|
||||
/>
|
||||
<span className='min-w-10 text-center text-xs tabular-nums'>{remainingLabel}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<TTSVoicesGroup[]>([]);
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl px-4 pt-4 sm:gap-1'>
|
||||
<div className='flex w-full flex-col items-center gap-0.5'>
|
||||
<input
|
||||
className='range'
|
||||
type='range'
|
||||
min={0.0}
|
||||
max={3.0}
|
||||
step='0.1'
|
||||
value={rate}
|
||||
onChange={handleSetRate}
|
||||
/>
|
||||
<div className='grid w-full grid-cols-7 text-xs'>
|
||||
<span className='text-center'>|</span>
|
||||
<span className='text-center'>|</span>
|
||||
<span className='text-center'>|</span>
|
||||
<span className='text-center'>|</span>
|
||||
<span className='text-center'>|</span>
|
||||
<span className='text-center'>|</span>
|
||||
<span className='text-center'>|</span>
|
||||
</div>
|
||||
<div className='grid w-full grid-cols-7 text-xs'>
|
||||
<span className='text-center'>{_('Slow')}</span>
|
||||
<span className='text-center'></span>
|
||||
<span className='text-center'>1.0</span>
|
||||
<span className='text-center'>1.5</span>
|
||||
<span className='text-center'>2.0</span>
|
||||
<span className='text-center'></span>
|
||||
<span className='text-center'>{_('Fast')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasTimeline && (
|
||||
<TTSProgressRow
|
||||
bookKey={bookKey}
|
||||
isEink={viewSettings?.isEink ?? false}
|
||||
onSeek={onSeek}
|
||||
onGetPlaybackInfo={onGetPlaybackInfo}
|
||||
/>
|
||||
)}
|
||||
<div className='flex items-center justify-between space-x-2'>
|
||||
<button
|
||||
onClick={() => onBackward()}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
title={_('Previous Paragraph')}
|
||||
aria-label={_('Previous Paragraph')}
|
||||
>
|
||||
<MdFastRewind size={iconSize32} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
title={isPlaying ? _('Pause') : _('Play')}
|
||||
aria-label={isPlaying ? _('Pause') : _('Play')}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<MdPauseCircle size={iconSize48} className='fill-primary' />
|
||||
) : (
|
||||
<MdPlayCircle size={iconSize48} className='fill-primary' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onForward()}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
title={_('Next Paragraph')}
|
||||
aria-label={_('Next Paragraph')}
|
||||
>
|
||||
<MdFastForward size={iconSize32} />
|
||||
</button>
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='flex flex-col items-center justify-center rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
title={_('Set Timeout')}
|
||||
aria-label={_('Set Timeout')}
|
||||
>
|
||||
<MdAlarm size={iconSize32} />
|
||||
{timeoutCountdown && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute bottom-0 left-1/2 w-12 translate-x-[-50%] translate-y-[80%] px-1',
|
||||
'bg-primary/80 text-base-100 rounded-full text-center text-xs',
|
||||
)}
|
||||
>
|
||||
{timeoutCountdown}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<ul
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
|
||||
'mt-4 inline max-h-96 w-[200px] overflow-y-scroll',
|
||||
)}
|
||||
>
|
||||
{timeoutOptions.map((option, index) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<li
|
||||
key={`${index}-${option.value}`}
|
||||
onClick={() => onSelectTimeout(bookKey, option.value)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span
|
||||
style={{
|
||||
width: `${defaultIconSize}px`,
|
||||
height: `${defaultIconSize}px`,
|
||||
}}
|
||||
>
|
||||
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span className={clsx('text-base sm:text-sm')}>{option.label}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<RiVoiceAiFill size={iconSize32} />
|
||||
</button>
|
||||
<ul
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
|
||||
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
|
||||
)}
|
||||
title={_('Select Voice')}
|
||||
aria-label={_('Select Voice')}
|
||||
>
|
||||
{voiceGroups.map((voiceGroup, index) => {
|
||||
return (
|
||||
<div key={voiceGroup.id} className=''>
|
||||
<div className='flex items-center gap-2 px-2 py-1'>
|
||||
<span
|
||||
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
|
||||
></span>
|
||||
<span className='text-sm text-gray-400 sm:text-xs'>
|
||||
{_('{{engine}}: {{count}} voices', {
|
||||
engine: _(voiceGroup.name),
|
||||
count: voiceGroup.voices.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{voiceGroup.voices.map((voice, voiceIndex) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<li
|
||||
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span
|
||||
style={{
|
||||
width: `${defaultIconSize}px`,
|
||||
height: `${defaultIconSize}px`,
|
||||
}}
|
||||
>
|
||||
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'max-w-[180px] overflow-hidden text-ellipsis text-base sm:text-sm',
|
||||
voice.disabled && 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
{_(voice.name)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-4 items-center justify-center opacity-60 transition-transform duration-200 hover:scale-105 hover:opacity-100'>
|
||||
<button
|
||||
onClick={onToogleTTSBar}
|
||||
className='p-0'
|
||||
title={_('Toggle Sticky Bottom TTS Bar')}
|
||||
aria-label={_('Toggle Sticky Bottom TTS Bar')}
|
||||
>
|
||||
{viewSettings?.showTTSBar ? (
|
||||
<TbChevronCompactUp size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
|
||||
) : (
|
||||
<TbChevronCompactDown size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTSPanel;
|
||||
@@ -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<TTSVoicesGroup[]>;
|
||||
onSetVoice: (voice: string, lang: string) => void;
|
||||
onGetVoiceId: () => string;
|
||||
onSelectTimeout: (bookKey: string, value: number) => void;
|
||||
onSeek: (seconds: number) => Promise<void>;
|
||||
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<SheetView>('main');
|
||||
const [voiceGroups, setVoiceGroups] = useState<TTSVoicesGroup[]>([]);
|
||||
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' ? (
|
||||
<div />
|
||||
) : (
|
||||
<div className='relative flex h-11 w-full items-center px-1'>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={_('Go Back')}
|
||||
onClick={() => setView('main')}
|
||||
className='btn btn-ghost btn-circle z-10 flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none'
|
||||
>
|
||||
<MdArrowBackIosNew size={iconSize24 * 0.8} className='rtl:rotate-180' />
|
||||
</button>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
|
||||
<span className='line-clamp-1 text-center font-bold'>
|
||||
{view === 'speed'
|
||||
? _('Speed')
|
||||
: view === 'voice'
|
||||
? _('Select Voice')
|
||||
: _('Set Timeout')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id='tts_player_sheet'
|
||||
isOpen={isOpen}
|
||||
snapHeight={0.65}
|
||||
title={_('Read Aloud')}
|
||||
header={header}
|
||||
boxClassName='sm:!h-auto sm:!max-h-[85%] sm:!w-[420px] sm:!min-w-0'
|
||||
contentClassName='!px-4 sm:!px-4 mt-[-4px]'
|
||||
onClose={onClose}
|
||||
>
|
||||
{view === 'main' && (
|
||||
<div className='flex w-full flex-col items-center gap-4 pb-4'>
|
||||
{book?.coverImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={book.coverImageUrl}
|
||||
alt=''
|
||||
className='not-eink:shadow-lg eink-bordered h-32 w-auto rounded-xl object-cover'
|
||||
/>
|
||||
) : null}
|
||||
<div className='flex w-full flex-col items-center gap-0.5 text-center'>
|
||||
<span className='line-clamp-1 font-semibold'>{book?.title ?? ''}</span>
|
||||
{sectionLabel && (
|
||||
<span className='text-base-content/70 line-clamp-1 text-sm'>{sectionLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
{hasTimeline ? (
|
||||
<TTSScrubber
|
||||
bookKey={bookKey}
|
||||
isEink={isEink}
|
||||
onSeek={onSeek}
|
||||
onGetPlaybackInfo={onGetPlaybackInfo}
|
||||
/>
|
||||
) : (
|
||||
chapterRemainingSec !== null && (
|
||||
<span className='text-base-content/70 text-xs'>
|
||||
{_('{{time}} left in chapter', { time: formatPlaybackTime(chapterRemainingSec) })}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<div dir='ltr' className='flex items-center justify-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
className='rounded-full p-2'
|
||||
title={_('Previous Paragraph')}
|
||||
aria-label={_('Previous Paragraph')}
|
||||
onClick={() => onBackward(false)}
|
||||
>
|
||||
<MdFastRewind size={iconSize24} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='rounded-full p-2'
|
||||
title={_('Previous Sentence')}
|
||||
aria-label={_('Previous Sentence')}
|
||||
onClick={() => onBackward(true)}
|
||||
>
|
||||
<MdSkipPrevious size={iconSize32} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-primary btn-circle mx-2 h-14 min-h-14 w-14'
|
||||
aria-label={isPlaying ? _('Pause') : _('Play')}
|
||||
onClick={onTogglePlay}
|
||||
>
|
||||
{isPlaying ? <MdOutlinePause size={iconSize32} /> : <MdPlayArrow size={iconSize32} />}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='rounded-full p-2'
|
||||
title={_('Next Sentence')}
|
||||
aria-label={_('Next Sentence')}
|
||||
onClick={() => onForward(true)}
|
||||
>
|
||||
<MdSkipNext size={iconSize32} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='rounded-full p-2'
|
||||
title={_('Next Paragraph')}
|
||||
aria-label={_('Next Paragraph')}
|
||||
onClick={() => onForward(false)}
|
||||
>
|
||||
<MdFastForward size={iconSize24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex w-full gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={_('Speed')}
|
||||
onClick={() => setView('speed')}
|
||||
className='not-eink:bg-base-200 eink-bordered flex h-14 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl'
|
||||
>
|
||||
<span className='text-sm font-semibold tabular-nums'>{formatRate(rate)}</span>
|
||||
<span className='text-base-content/60 text-xs'>{_('Speed')}</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={_('Voice')}
|
||||
onClick={() => setView('voice')}
|
||||
className='not-eink:bg-base-200 eink-bordered flex h-14 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl'
|
||||
>
|
||||
<RiVoiceAiFill size={iconSize18} />
|
||||
<span className='text-base-content/60 max-w-full truncate px-1 text-xs'>
|
||||
{currentVoiceName ?? _('Voice')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={_('Sleep Timer')}
|
||||
onClick={() => setView('timer')}
|
||||
className='not-eink:bg-base-200 eink-bordered flex h-14 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl'
|
||||
>
|
||||
<MdAlarm size={iconSize18} />
|
||||
<span className='text-base-content/60 max-w-full truncate px-1 text-xs tabular-nums'>
|
||||
{timerCaption}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === 'speed' && (
|
||||
<div className='flex w-full flex-col items-center pb-4 pt-2'>
|
||||
<SpeedChips rate={rate} onSelect={handleSelectRate} />
|
||||
</div>
|
||||
)}
|
||||
{view === 'voice' && (
|
||||
<div className='flex w-full flex-col pb-4'>
|
||||
{voiceGroups.map((voiceGroup) => (
|
||||
<div key={voiceGroup.id}>
|
||||
<div className='text-base-content/60 px-2 py-1 text-sm sm:text-xs'>
|
||||
{_('{{engine}}: {{count}} voices', {
|
||||
engine: _(voiceGroup.name),
|
||||
count: voiceGroup.voices.length,
|
||||
})}
|
||||
</div>
|
||||
{voiceGroup.voices.map((voice) => (
|
||||
<button
|
||||
key={`${voiceGroup.id}-${voice.id}`}
|
||||
type='button'
|
||||
disabled={voice.disabled}
|
||||
onClick={() => handleSelectVoice(voice.id, voice.lang)}
|
||||
className='flex w-full items-center gap-2 rounded-lg px-2 py-2 text-start'
|
||||
>
|
||||
<span className='flex h-6 w-6 items-center justify-center'>
|
||||
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'overflow-hidden text-ellipsis text-base sm:text-sm',
|
||||
voice.disabled && 'text-base-content/40',
|
||||
)}
|
||||
>
|
||||
{_(voice.name)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{view === 'timer' && (
|
||||
<div className='flex w-full flex-col pb-4'>
|
||||
{timeoutOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => handleSelectTimeout(option.value)}
|
||||
className='flex w-full items-center gap-2 rounded-lg px-2 py-2 text-start'
|
||||
>
|
||||
<span className='flex h-6 w-6 items-center justify-center'>
|
||||
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span className='text-base sm:text-sm'>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTSPlayerSheet;
|
||||
@@ -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<void>;
|
||||
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<number | null>(null);
|
||||
const dragValueRef = useRef<number | null>(null);
|
||||
const keyboardCommitRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
|
||||
// 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 (
|
||||
<div dir='ltr' className={clsx('flex w-full items-center gap-2 py-1', stale && 'opacity-60')}>
|
||||
<span className='min-w-9 text-center text-xs tabular-nums'>{elapsedLabel}</span>
|
||||
<input
|
||||
className='tts-scrubber text-base-content min-w-0 grow'
|
||||
type='range'
|
||||
min={0}
|
||||
max={total || 1}
|
||||
step={step}
|
||||
value={position}
|
||||
disabled={!ready || stale}
|
||||
onChange={handleChange}
|
||||
onPointerUp={handlePointerCommit}
|
||||
onTouchEnd={handlePointerCommit}
|
||||
onKeyUp={handleKeyUp}
|
||||
style={{
|
||||
background: `linear-gradient(to right, currentColor 0% ${playedPct}%, color-mix(in srgb, currentColor 40%, transparent) ${playedPct}% ${bufferedPct}%, color-mix(in srgb, currentColor 15%, transparent) ${bufferedPct}% 100%)`,
|
||||
}}
|
||||
aria-label={_('Chapter progress')}
|
||||
aria-valuetext={_('{{elapsed}} of {{total}}', {
|
||||
elapsed: elapsedLabel,
|
||||
total: ready ? formatPlaybackTime(total, forceHours) : '--:--',
|
||||
})}
|
||||
/>
|
||||
<span className='min-w-10 text-center text-xs tabular-nums'>{remainingLabel}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTSScrubber;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<TTSPlaybackInfo | null>(null);
|
||||
const [stale, setStale] = useState(true);
|
||||
const [displayTotal, setDisplayTotal] = useState<number | null>(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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, VoiceCalibration> => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CALIBRATION_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Record<string, VoiceCalibration>;
|
||||
const parsed = JSON.parse(raw) as Record<
|
||||
string,
|
||||
Partial<VoiceCalibration> & { 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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -317,7 +317,6 @@ export interface TTSConfig {
|
||||
ttsRate: number;
|
||||
ttsVoice: string;
|
||||
ttsLocation: string;
|
||||
showTTSBar: boolean;
|
||||
ttsHighlightOptions: TTSHighlightOptions;
|
||||
ttsHighlightGranularity: TTSHighlightGranularity;
|
||||
ttsMediaMetadata: TTSMediaMetadataMode;
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user