forked from akai/readest
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f64739419b | |||
| af8f036ca3 | |||
| 24a87508c6 | |||
| 548d50a882 | |||
| 1e81ab5205 | |||
| a47a480aa2 | |||
| 411f9e236d | |||
| 950bbd0821 | |||
| 2824e50b51 | |||
| 129720c916 | |||
| fd3533dba1 | |||
| 920032155b | |||
| 226bf7033e | |||
| 9444be7fcc | |||
| c9a69a922b | |||
| 9a11b05833 | |||
| 6e603ee38f | |||
| 03fd6e2e6f | |||
| 7dd11c0fb0 | |||
| 968597e52c | |||
| a534050b19 | |||
| 0be828fd66 | |||
| c6d4e2bdd6 | |||
| bf72ab86cd | |||
| fb49ddf484 | |||
| d8817a88b9 | |||
| 475becafe3 | |||
| df165576e6 | |||
| 051f2e5b13 |
@@ -11,8 +11,6 @@ target
|
||||
.gitignore
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
**/dist
|
||||
.next
|
||||
**/.next
|
||||
out
|
||||
|
||||
@@ -45,24 +45,24 @@
|
||||
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
|
||||
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
|
||||
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
|
||||
@@ -176,7 +176,9 @@ For Android:
|
||||
|
||||
```bash
|
||||
# Initialize the Android environment (run once)
|
||||
rm apps/readest-app/src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
git checkout apps/readest-app/src-tauri/gen/android
|
||||
|
||||
pnpm tauri android dev
|
||||
# or if you want to dev on a real device
|
||||
@@ -202,6 +204,9 @@ pnpm tauri android build
|
||||
pnpm tauri ios build
|
||||
```
|
||||
|
||||
Please refer to our release script if you experience any issues:
|
||||
https://github.com/readest/readest/blob/main/.github/workflows/release.yml
|
||||
|
||||
### 6. Setup dev environment with Nix
|
||||
|
||||
If you have Nix installed, you can leverage flake to enter a development shell
|
||||
@@ -250,6 +255,32 @@ Please check the [wiki][link-gh-wiki] of this project for more information on de
|
||||
|
||||
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps you’ve taken.
|
||||
|
||||
### 2. AppImage Launches but Only Shows a Taskbar Icon
|
||||
|
||||
On some Arch Linux systems—especially those using Wayland—the Readest AppImage may briefly show an icon in the taskbar and then exit without opening a window.
|
||||
|
||||
You might see logs such as:
|
||||
|
||||
```
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
This behavior is usually caused by compatibility issues between the bundled AppImage libraries and the system’s EGL / Wayland environment.
|
||||
|
||||
**Workaround 1: Launch with LD_PRELOAD (recommended)**
|
||||
|
||||
You can preload the system Wayland client library before launching the AppImage:
|
||||
|
||||
```
|
||||
LD_PRELOAD=/usr/lib/libwayland-client.so /path/to/Readest.AppImage
|
||||
```
|
||||
|
||||
This workaround has been confirmed to resolve the issue on affected systems.
|
||||
|
||||
**Workaround 2: Use the Flatpak Version**
|
||||
|
||||
If you prefer a more reliable out-of-the-box experience on Arch Linux, consider using the [Flatpak build on Flathub][link-flathub] instead. The Flatpak runtime helps avoid system library mismatches and tends to behave more consistently across different Wayland and X11 setups.
|
||||
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
@@ -262,12 +293,12 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
|
||||
|
||||
## Support
|
||||
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [contribute with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
|
||||
### Sponsors
|
||||
|
||||
<p align="center">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmu.ai?utm_source=readest&utm_medium=sponsor" target="_blank">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=readest" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
|
||||
</a>
|
||||
</p>
|
||||
@@ -291,7 +322,9 @@ The following libraries and frameworks are used in this software:
|
||||
|
||||
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
|
||||
|
||||
[Bitter](https://fonts.google.com/?query=Bitter), [Fira Code](https://fonts.google.com/?query=Fira+Code), [Literata](https://fonts.google.com/?query=Literata), [Merriweather](https://fonts.google.com/?query=Merriweather), [Noto Sans](https://fonts.google.com/?query=Noto+Sans), [Roboto](https://fonts.google.com/?query=Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
[Bitter](https://fonts.google.com/specimen/Bitter), [Fira Code](https://fonts.google.com/specimen/Fira+Code), [Inter](https://fonts.google.com/specimen/Inter), [Literata](https://fonts.google.com/specimen/Literata), [Merriweather](https://fonts.google.com/specimen/Merriweather), [Noto Sans](https://fonts.google.com/specimen/Noto+Sans), [Roboto](https://fonts.google.com/specimen/Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
|
||||
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ module.exports = {
|
||||
'id',
|
||||
'vi',
|
||||
'ms',
|
||||
'he',
|
||||
'ar',
|
||||
'th',
|
||||
'bo',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.99",
|
||||
"version": "0.9.100",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
|
||||
@@ -1024,7 +1024,6 @@
|
||||
"Unable to start RSVP": "تعذر بدء RSVP",
|
||||
"RSVP not supported for PDF": "RSVP غير مدعوم لملفات PDF",
|
||||
"Select Chapter": "اختر الفصل",
|
||||
"{{number}} WPM": "{{number}} كلمة في الدقيقة",
|
||||
"Context": "السياق",
|
||||
"Ready": "جاهز",
|
||||
"Chapter Progress": "تقدم الفصل",
|
||||
@@ -1079,5 +1078,6 @@
|
||||
"Authors": "المؤلفون",
|
||||
"Books": "الكتب",
|
||||
"Groups": "المجموعات",
|
||||
"Back to TTS Location": "العودة إلى موقع القراءة الآلية"
|
||||
"Back to TTS Location": "العودة إلى موقع القراءة الآلية",
|
||||
"Metadata": "بيانات وصفية"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "RSVP शुरू করতে অসমর্থ",
|
||||
"RSVP not supported for PDF": "PDF-এর জন্য RSVP সমর্থিত নয়",
|
||||
"Select Chapter": "অধ্যায় নির্বাচন করুন",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "প্রসঙ্গ",
|
||||
"Ready": "প্রস্তুত",
|
||||
"Chapter Progress": "অধ্যায় অগ্রগতি",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "লেখকগণ",
|
||||
"Books": "বই",
|
||||
"Groups": "দল",
|
||||
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান"
|
||||
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান",
|
||||
"Metadata": "মেটাডেটা"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "RSVP འགོ་འཛུགས་མ་ཐུབ།",
|
||||
"RSVP not supported for PDF": "PDF ལ་ RSVP རྒྱབ་སྐྱོར་མེད།",
|
||||
"Select Chapter": "ལེའུ་འདེམས་པ།",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "བརྗོད་དོན།",
|
||||
"Ready": "གྲ་སྒྲིག་ཡོད།",
|
||||
"Chapter Progress": "ལེའུའི་འཕེལ་རིམ།",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "རྩོམ་པ་པོ།",
|
||||
"Books": "དཔེ་ཆ།",
|
||||
"Groups": "ཚོགས་པ།",
|
||||
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།"
|
||||
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།",
|
||||
"Metadata": "གནད་སྨིན་གོ་དོན།"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "RSVP kann nicht gestartet werden",
|
||||
"RSVP not supported for PDF": "RSVP wird für PDF nicht unterstützt",
|
||||
"Select Chapter": "Kapitel auswählen",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Kontext",
|
||||
"Ready": "Bereit",
|
||||
"Chapter Progress": "Kapitelfortschritt",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "Autoren",
|
||||
"Books": "Bücher",
|
||||
"Groups": "Gruppen",
|
||||
"Back to TTS Location": "Zurück zur TTS-Position"
|
||||
"Back to TTS Location": "Zurück zur TTS-Position",
|
||||
"Metadata": "Metadaten"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "Αδυναμία έναρξης RSVP",
|
||||
"RSVP not supported for PDF": "Το RSVP δεν υποστηρίζεται για PDF",
|
||||
"Select Chapter": "Επιλογή Κεφαλαίου",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Πλαίσιο",
|
||||
"Ready": "Έτοιμο",
|
||||
"Chapter Progress": "Πρόοδος Κεφαλαίου",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "Συγγραφείς",
|
||||
"Books": "Βιβλία",
|
||||
"Groups": "Ομάδες",
|
||||
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS"
|
||||
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS",
|
||||
"Metadata": "Μεταδεδομένα"
|
||||
}
|
||||
|
||||
@@ -988,7 +988,6 @@
|
||||
"Unable to start RSVP": "No se puede iniciar RSVP",
|
||||
"RSVP not supported for PDF": "RSVP no es compatible con PDF",
|
||||
"Select Chapter": "Seleccionar capítulo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contexto",
|
||||
"Ready": "Listo",
|
||||
"Chapter Progress": "Progreso del capítulo",
|
||||
@@ -1043,5 +1042,6 @@
|
||||
"Authors": "Autores",
|
||||
"Books": "Libros",
|
||||
"Groups": "Grupos",
|
||||
"Back to TTS Location": "Volver a la ubicación de TTS"
|
||||
"Back to TTS Location": "Volver a la ubicación de TTS",
|
||||
"Metadata": "Metadatos"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "قادر به شروع RSVP نیست",
|
||||
"RSVP not supported for PDF": "RSVP برای PDF پشتیبانی نمیشود",
|
||||
"Select Chapter": "انتخاب فصل",
|
||||
"{{number}} WPM": "{{number}} کلمه در دقیقه",
|
||||
"Context": "زمینه",
|
||||
"Ready": "آماده",
|
||||
"Chapter Progress": "پیشرفت فصل",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "نویسندگان",
|
||||
"Books": "کتابها",
|
||||
"Groups": "گروهها",
|
||||
"Back to TTS Location": "بازگشت به مکان TTS"
|
||||
"Back to TTS Location": "بازگشت به مکان TTS",
|
||||
"Metadata": "فراداده"
|
||||
}
|
||||
|
||||
@@ -988,7 +988,6 @@
|
||||
"Unable to start RSVP": "Impossible de démarrer RSVP",
|
||||
"RSVP not supported for PDF": "RSVP non pris en charge pour PDF",
|
||||
"Select Chapter": "Sélectionner le chapitre",
|
||||
"{{number}} WPM": "{{number}} MPM",
|
||||
"Context": "Contexte",
|
||||
"Ready": "Prêt",
|
||||
"Chapter Progress": "Progression du chapitre",
|
||||
@@ -1043,5 +1042,6 @@
|
||||
"Authors": "Auteurs",
|
||||
"Books": "Livres",
|
||||
"Groups": "Groupes",
|
||||
"Back to TTS Location": "Retour à l'emplacement TTS"
|
||||
"Back to TTS Location": "Retour à l'emplacement TTS",
|
||||
"Metadata": "Métadonnées"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "RSVP शुरू करने में असमर्थ",
|
||||
"RSVP not supported for PDF": "PDF के लिए RSVP समर्थित नहीं है",
|
||||
"Select Chapter": "अध्याय चुनें",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "संदर्भ",
|
||||
"Ready": "तैयार",
|
||||
"Chapter Progress": "अध्याय प्रगति",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "लेखक",
|
||||
"Books": "पुस्तकें",
|
||||
"Groups": "समूह",
|
||||
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं"
|
||||
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं",
|
||||
"Metadata": "मेटाडाटा"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "Tidak dapat memulai RSVP",
|
||||
"RSVP not supported for PDF": "RSVP tidak didukung untuk PDF",
|
||||
"Select Chapter": "Pilih Bab",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Konteks",
|
||||
"Ready": "Siap",
|
||||
"Chapter Progress": "Kemajuan Bab",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "Penulis",
|
||||
"Books": "Buku",
|
||||
"Groups": "Grup",
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS"
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS",
|
||||
"Metadata": "Metadata"
|
||||
}
|
||||
|
||||
@@ -988,7 +988,6 @@
|
||||
"Unable to start RSVP": "Impossibile avviare RSVP",
|
||||
"RSVP not supported for PDF": "RSVP non supportato per PDF",
|
||||
"Select Chapter": "Seleziona capitolo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contesto",
|
||||
"Ready": "Pronto",
|
||||
"Chapter Progress": "Progresso capitolo",
|
||||
@@ -1043,5 +1042,6 @@
|
||||
"Authors": "Autori",
|
||||
"Books": "Libri",
|
||||
"Groups": "Gruppi",
|
||||
"Back to TTS Location": "Torna alla posizione TTS"
|
||||
"Back to TTS Location": "Torna alla posizione TTS",
|
||||
"Metadata": "Metadati"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "RSVPを起動できません",
|
||||
"RSVP not supported for PDF": "PDFはRSVPに対応していません",
|
||||
"Select Chapter": "章を選択",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "コンテキスト",
|
||||
"Ready": "準備完了",
|
||||
"Chapter Progress": "章の進捗",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "著者",
|
||||
"Books": "書籍",
|
||||
"Groups": "グループ",
|
||||
"Back to TTS Location": "TTSの位置に戻る"
|
||||
"Back to TTS Location": "TTSの位置に戻る",
|
||||
"Metadata": "メタデータ"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "RSVP를 시작할 수 없습니다",
|
||||
"RSVP not supported for PDF": "PDF는 RSVP를 지원하지 않습니다",
|
||||
"Select Chapter": "챕터 선택",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "문맥",
|
||||
"Ready": "준비됨",
|
||||
"Chapter Progress": "챕터 진행 상황",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "저자",
|
||||
"Books": "도서",
|
||||
"Groups": "그룹",
|
||||
"Back to TTS Location": "TTS 위치로 돌아가기"
|
||||
"Back to TTS Location": "TTS 위치로 돌아가기",
|
||||
"Metadata": "메타데이터"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "Tidak dapat memulakan RSVP",
|
||||
"RSVP not supported for PDF": "RSVP tidak disokong untuk PDF",
|
||||
"Select Chapter": "Pilih Bab",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Konteks",
|
||||
"Ready": "Sedia",
|
||||
"Chapter Progress": "Kemajuan Bab",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "Penulis",
|
||||
"Books": "Buku",
|
||||
"Groups": "Kumpulan",
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS"
|
||||
"Back to TTS Location": "Kembali ke Lokasi TTS",
|
||||
"Metadata": "Metadata"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "Kan RSVP niet starten",
|
||||
"RSVP not supported for PDF": "RSVP niet ondersteund voor PDF",
|
||||
"Select Chapter": "Hoofdstuk selecteren",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Context",
|
||||
"Ready": "Klaar",
|
||||
"Chapter Progress": "Hoofdstukvoortgang",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "Auteurs",
|
||||
"Books": "Boeken",
|
||||
"Groups": "Groepen",
|
||||
"Back to TTS Location": "Terug naar TTS-locatie"
|
||||
"Back to TTS Location": "Terug naar TTS-locatie",
|
||||
"Metadata": "Metadata"
|
||||
}
|
||||
|
||||
@@ -1000,7 +1000,6 @@
|
||||
"Unable to start RSVP": "Nie można uruchomić RSVP",
|
||||
"RSVP not supported for PDF": "RSVP nie jest obsługiwane dla PDF",
|
||||
"Select Chapter": "Wybierz rozdział",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Kontekst",
|
||||
"Ready": "Gotowy",
|
||||
"Chapter Progress": "Postęp rozdziału",
|
||||
@@ -1055,5 +1054,6 @@
|
||||
"Authors": "Autorzy",
|
||||
"Books": "Książki",
|
||||
"Groups": "Grupy",
|
||||
"Back to TTS Location": "Powrót do lokalizacji TTS"
|
||||
"Back to TTS Location": "Powrót do lokalizacji TTS",
|
||||
"Metadata": "Metadane"
|
||||
}
|
||||
|
||||
@@ -988,7 +988,6 @@
|
||||
"Unable to start RSVP": "Não foi possível iniciar o RSVP",
|
||||
"RSVP not supported for PDF": "RSVP não suportado para PDF",
|
||||
"Select Chapter": "Selecionar capítulo",
|
||||
"{{number}} WPM": "{{number}} PPM",
|
||||
"Context": "Contexto",
|
||||
"Ready": "Pronto",
|
||||
"Chapter Progress": "Progresso do capítulo",
|
||||
@@ -1043,5 +1042,6 @@
|
||||
"Authors": "Autores",
|
||||
"Books": "Livros",
|
||||
"Groups": "Grupos",
|
||||
"Back to TTS Location": "Voltar para a localização do TTS"
|
||||
"Back to TTS Location": "Voltar para a localização do TTS",
|
||||
"Metadata": "Metadados"
|
||||
}
|
||||
|
||||
@@ -1000,7 +1000,6 @@
|
||||
"Unable to start RSVP": "Не удалось запустить RSVP",
|
||||
"RSVP not supported for PDF": "RSVP не поддерживается для PDF",
|
||||
"Select Chapter": "Выбрать главу",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Контекст",
|
||||
"Ready": "Готово",
|
||||
"Chapter Progress": "Прогресс главы",
|
||||
@@ -1055,5 +1054,6 @@
|
||||
"Authors": "Авторы",
|
||||
"Books": "Книги",
|
||||
"Groups": "Группы",
|
||||
"Back to TTS Location": "Вернуться к местоположению TTS"
|
||||
"Back to TTS Location": "Вернуться к местоположению TTS",
|
||||
"Metadata": "Метаданные"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "RSVP ආරම්භ කිරීමට නොහැක",
|
||||
"RSVP not supported for PDF": "PDF සඳහා RSVP සහාය නොදක්වයි",
|
||||
"Select Chapter": "පරිච්ඡේදය තෝරන්න",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "සන්දර්භය",
|
||||
"Ready": "සූදානම්",
|
||||
"Chapter Progress": "පරිච්ඡේදයේ ප්රගතිය",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "කතුවරුන්",
|
||||
"Books": "පොත්",
|
||||
"Groups": "කණ්ඩායම්",
|
||||
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න"
|
||||
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න",
|
||||
"Metadata": "මෙටාඩේටා"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "Kunde inte starta RSVP",
|
||||
"RSVP not supported for PDF": "RSVP stöds inte för PDF",
|
||||
"Select Chapter": "Välj kapitel",
|
||||
"{{number}} WPM": "{{number}} ord/min",
|
||||
"Context": "Sammanhang",
|
||||
"Ready": "Klar",
|
||||
"Chapter Progress": "Kapitelframsteg",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "Författare",
|
||||
"Books": "Böcker",
|
||||
"Groups": "Grupper",
|
||||
"Back to TTS Location": "Tillbaka till TTS-plats"
|
||||
"Back to TTS Location": "Tillbaka till TTS-plats",
|
||||
"Metadata": "Metadata"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "RSVP-ஐத் தொடங்க முடியவில்லை",
|
||||
"RSVP not supported for PDF": "PDF-க்கு RSVP ஆதரவு இல்லை",
|
||||
"Select Chapter": "அத்தியாயத்தைத் தேர்ந்தெடு",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "சூழல்",
|
||||
"Ready": "தயார்",
|
||||
"Chapter Progress": "அத்தியாய முன்னேற்றம்",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "ஆசிரியர்கள்",
|
||||
"Books": "புத்தகங்கள்",
|
||||
"Groups": "குழுக்கள்",
|
||||
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு"
|
||||
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு",
|
||||
"Metadata": "மெட்டாடேட்டா"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "ไม่สามารถเริ่ม RSVP ได้",
|
||||
"RSVP not supported for PDF": "ไม่รองรับ RSVP สำหรับ PDF",
|
||||
"Select Chapter": "เลือกบท",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "บริบท",
|
||||
"Ready": "พร้อม",
|
||||
"Chapter Progress": "ความคืบหน้าของบท",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "ผู้เขียน",
|
||||
"Books": "หนังสือ",
|
||||
"Groups": "กลุ่ม",
|
||||
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS"
|
||||
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS",
|
||||
"Metadata": "เมทาดาตา"
|
||||
}
|
||||
|
||||
@@ -976,7 +976,6 @@
|
||||
"Unable to start RSVP": "RSVP başlatılamadı",
|
||||
"RSVP not supported for PDF": "PDF için RSVP desteklenmiyor",
|
||||
"Select Chapter": "Bölüm Seç",
|
||||
"{{number}} WPM": "{{number}} Kelime/Dakika",
|
||||
"Context": "Bağlam",
|
||||
"Ready": "Hazır",
|
||||
"Chapter Progress": "Bölüm İlerlemesi",
|
||||
@@ -1031,5 +1030,6 @@
|
||||
"Authors": "Yazarlar",
|
||||
"Books": "Kitaplar",
|
||||
"Groups": "Gruplar",
|
||||
"Back to TTS Location": "TTS Konumuna Geri Dön"
|
||||
"Back to TTS Location": "TTS Konumuna Geri Dön",
|
||||
"Metadata": "Meta veriler"
|
||||
}
|
||||
|
||||
@@ -1000,7 +1000,6 @@
|
||||
"Unable to start RSVP": "Не вдалося запустити RSVP",
|
||||
"RSVP not supported for PDF": "RSVP не підтримується для PDF",
|
||||
"Select Chapter": "Вибрати розділ",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Контекст",
|
||||
"Ready": "Готово",
|
||||
"Chapter Progress": "Прогрес розділу",
|
||||
@@ -1055,5 +1054,6 @@
|
||||
"Authors": "Автори",
|
||||
"Books": "Книги",
|
||||
"Groups": "Групи",
|
||||
"Back to TTS Location": "Повернутися до розташування TTS"
|
||||
"Back to TTS Location": "Повернутися до розташування TTS",
|
||||
"Metadata": "Метадані"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "Không thể bắt đầu RSVP",
|
||||
"RSVP not supported for PDF": "RSVP không được hỗ trợ cho PDF",
|
||||
"Select Chapter": "Chọn chương",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "Ngữ cảnh",
|
||||
"Ready": "Sẵn sàng",
|
||||
"Chapter Progress": "Tiến độ chương",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "Tác giả",
|
||||
"Books": "Sách",
|
||||
"Groups": "Nhóm",
|
||||
"Back to TTS Location": "Quay lại vị trí TTS"
|
||||
"Back to TTS Location": "Quay lại vị trí TTS",
|
||||
"Metadata": "Siêu dữ liệu"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "无法启动 RSVP",
|
||||
"RSVP not supported for PDF": "PDF 不支持 RSVP",
|
||||
"Select Chapter": "选择章节",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "上下文",
|
||||
"Ready": "准备就绪",
|
||||
"Chapter Progress": "章节进度",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "作者",
|
||||
"Books": "书籍",
|
||||
"Groups": "分组",
|
||||
"Back to TTS Location": "回到朗读位置"
|
||||
"Back to TTS Location": "回到朗读位置",
|
||||
"Metadata": "元数据"
|
||||
}
|
||||
|
||||
@@ -964,7 +964,6 @@
|
||||
"Unable to start RSVP": "無法啟動 RSVP",
|
||||
"RSVP not supported for PDF": "PDF 不支援 RSVP",
|
||||
"Select Chapter": "選擇章節",
|
||||
"{{number}} WPM": "{{number}} WPM",
|
||||
"Context": "上下文",
|
||||
"Ready": "準備就緒",
|
||||
"Chapter Progress": "章節進度",
|
||||
@@ -1019,5 +1018,6 @@
|
||||
"Authors": "作者",
|
||||
"Books": "書籍",
|
||||
"Groups": "分組",
|
||||
"Back to TTS Location": "回到朗讀位置"
|
||||
"Back to TTS Location": "回到朗讀位置",
|
||||
"Metadata": "元數據"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.100": {
|
||||
"date": "2026-02-13",
|
||||
"notes": [
|
||||
"Text-to-Speech: Automatically moves to the next chapter when the current chapter finishes",
|
||||
"Reading: Fixed the reading ruler on Android",
|
||||
"Reading: Improved RSVP layout and stability",
|
||||
"Reading: Fixed an issue where some EPUB books could not adjust font size",
|
||||
"Annotations: Added a magnifier (loupe) when adjusting text selection",
|
||||
"Annotations: Text selection now snaps to word boundaries for better accuracy",
|
||||
"Annotations: Custom highlight colors now apply correctly in the sidebar",
|
||||
"Dictionary: Added a back button for easier navigation",
|
||||
"Dictionary: Fixed an issue where Dictionary and Wikipedia could not open on Android",
|
||||
"Metadata: Added collapsible sections in Book Details",
|
||||
"Metadata: Added support for parsing series information from Calibre-exported EPUB files",
|
||||
"Eink: Improved component layout and colors in E-ink mode",
|
||||
"Layout: Sidebar toggle button position is now persistent"
|
||||
]
|
||||
},
|
||||
"0.9.99": {
|
||||
"date": "2026-02-07",
|
||||
"notes": [
|
||||
|
||||
@@ -54,17 +54,6 @@
|
||||
|
||||
DetailPrint "Thumbnail provider registered successfully."
|
||||
|
||||
Delete "$DESKTOP\Readest.lnk"
|
||||
Delete "$SMPROGRAMS\Readest\Readest.lnk"
|
||||
RMDir "$SMPROGRAMS\Readest"
|
||||
|
||||
; Create new shortcuts pointing to current installation
|
||||
CreateShortcut "$DESKTOP\Readest.lnk" "$INSTDIR\Readest.exe"
|
||||
CreateDirectory "$SMPROGRAMS\Readest"
|
||||
CreateShortcut "$SMPROGRAMS\Readest\Readest.lnk" "$INSTDIR\Readest.exe"
|
||||
|
||||
DetailPrint "Shortcuts updated."
|
||||
|
||||
; Refresh shell to apply changes - SHCNE_ASSOCCHANGED
|
||||
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
|
||||
!macroend
|
||||
|
||||
@@ -103,7 +103,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
style={{
|
||||
marginTop: appService?.hasSafeAreaInset
|
||||
? `max(${insets.top}px, ${systemUIVisible ? statusBarHeight : 0}px)`
|
||||
: '0px',
|
||||
: appService?.hasTrafficLight
|
||||
? '-2px'
|
||||
: '0px',
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
|
||||
|
||||
@@ -26,17 +26,19 @@ const SetStatusAlert: React.FC<SetStatusAlertProps> = ({
|
||||
label: _('Mark as Unread'),
|
||||
status: 'unread' as ReadingStatus,
|
||||
className:
|
||||
'bg-amber-500/15 text-amber-600 dark:text-amber-400 hover:bg-amber-500/25 border-amber-500/20',
|
||||
'not-eink:bg-amber-500/15 not-eink:text-amber-600 dark:not-eink:text-amber-400 not-eink:border-amber-500/20 eink-bordered',
|
||||
},
|
||||
{
|
||||
label: _('Mark as Finished'),
|
||||
status: 'finished' as ReadingStatus,
|
||||
className: 'bg-success/15 text-success hover:bg-success/25 border-success/20',
|
||||
className:
|
||||
'not-eink:bg-success/15 not-eink:text-success not-eink:border-success/20 eink-bordered',
|
||||
},
|
||||
{
|
||||
label: _('Clear Status'),
|
||||
status: undefined,
|
||||
className: 'bg-base-300 text-base-content hover:bg-base-content/10 border-base-content/10',
|
||||
className:
|
||||
'not-eink:bg-base-300 not-eink:text-base-content not-eink:border-base-content/10 eink-bordered',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -89,9 +91,10 @@ const SetStatusAlert: React.FC<SetStatusAlertProps> = ({
|
||||
))}
|
||||
<button
|
||||
className={clsx(
|
||||
'border-base-content/10 hidden items-center gap-2 rounded-full border px-4 py-2',
|
||||
'bg-base-300 text-base-content shadow-sm',
|
||||
'hover:bg-base-content/10 transition-all duration-200 ease-out active:scale-[0.97]',
|
||||
'hidden items-center gap-2 rounded-full border px-4 py-2',
|
||||
'not-eink:bg-base-300 not-eink:text-base-content not-eink:border-base-content/10 not-eink:shadow-sm',
|
||||
'eink-bordered',
|
||||
'transition-all duration-200 ease-out active:scale-[0.97]',
|
||||
'sm:flex',
|
||||
)}
|
||||
onClick={onCancel}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { setMigrateDataDirDialogVisible } from '@/app/library/components/Migrate
|
||||
import { requestStoragePermission } from '@/utils/permission';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { selectDirectory } from '@/utils/bridge';
|
||||
import { formatLocaleDateTime } from '@/utils/book';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
@@ -277,7 +278,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
label={
|
||||
settings.lastSyncedAtBooks
|
||||
? _('Synced at {{time}}', {
|
||||
time: new Date(settings.lastSyncedAtBooks).toLocaleString(),
|
||||
time: formatLocaleDateTime(settings.lastSyncedAtBooks),
|
||||
})
|
||||
: _('Never synced')
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
{ label: _('Title'), value: LibrarySortByType.Title },
|
||||
{ label: _('Author'), value: LibrarySortByType.Author },
|
||||
{ label: _('Format'), value: LibrarySortByType.Format },
|
||||
{ label: _('Series'), value: LibrarySortByType.Series },
|
||||
{ label: _('Date Read'), value: LibrarySortByType.Updated },
|
||||
{ label: _('Date Added'), value: LibrarySortByType.Created },
|
||||
{ label: _('Date Published'), value: LibrarySortByType.Published },
|
||||
|
||||
@@ -105,6 +105,8 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
case LibrarySortByType.Format:
|
||||
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
|
||||
case LibrarySortByType.Series:
|
||||
return (a.metadata?.seriesIndex || 0) - (b.metadata?.seriesIndex || 0);
|
||||
case LibrarySortByType.Published:
|
||||
const aPublished = a.metadata?.published || '0001-01-01';
|
||||
const bPublished = b.metadata?.published || '0001-01-01';
|
||||
|
||||
@@ -27,9 +27,10 @@ import DoubleBorder from './DoubleBorder';
|
||||
interface BooksGridProps {
|
||||
bookKeys: string[];
|
||||
onCloseBook: (bookKey: string) => void;
|
||||
onGoToLibrary: () => void;
|
||||
}
|
||||
|
||||
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibrary }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
@@ -124,6 +125,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
isTopLeft={index === 0}
|
||||
isHoveredAnim={bookKeys.length > 2}
|
||||
onCloseBook={onCloseBook}
|
||||
onGoToLibrary={onGoToLibrary}
|
||||
gridInsets={gridInsets}
|
||||
onDropdownOpenChange={(isOpen) => setDropdownOpenBook(isOpen ? bookKey : '')}
|
||||
/>
|
||||
@@ -210,7 +212,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
{showFooter && (
|
||||
<ProgressInfoView
|
||||
bookKey={bookKey}
|
||||
sections={bookDoc.sections || []}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
contentInsets={contentInsets}
|
||||
gridInsets={gridInsets}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { VscLibrary } from 'react-icons/vsc';
|
||||
|
||||
import { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -34,6 +35,7 @@ interface HeaderBarProps {
|
||||
isHoveredAnim: boolean;
|
||||
gridInsets: Insets;
|
||||
onCloseBook: (bookKey: string) => void;
|
||||
onGoToLibrary: () => void;
|
||||
onDropdownOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -44,6 +46,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
isHoveredAnim,
|
||||
gridInsets,
|
||||
onCloseBook,
|
||||
onGoToLibrary,
|
||||
onDropdownOpenChange,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
@@ -60,6 +63,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const view = getView(bookKey);
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
|
||||
@@ -147,7 +151,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
className={clsx(
|
||||
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
|
||||
`shadow-xs transition-[opacity,margin-top] duration-300`,
|
||||
trafficLightInHeader ? 'pl-20' : 'pl-4',
|
||||
trafficLightInHeader ? 'pl-20' : isSideBarVisible ? 'ps-4' : 'ps-4 sm:ps-1.5',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
@@ -167,9 +171,18 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className='header-tools-start bg-base-100 sidebar-bookmark-toggler z-20 flex h-full items-center gap-x-4 pe-2 max-[350px]:gap-x-2'>
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
{!isSideBarVisible && (
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
title={_('Go to Library')}
|
||||
className='btn btn-ghost hidden h-8 min-h-8 w-8 p-0 sm:flex'
|
||||
onClick={onGoToLibrary}
|
||||
>
|
||||
<VscLibrary size={iconSize18} className='fill-base-content' />
|
||||
</button>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
{enableAnnotationQuickActions && (
|
||||
|
||||
@@ -67,7 +67,7 @@ const PageNavigationButtons: React.FC<PageNavigationButtonsProps> = ({
|
||||
'absolute left-2 -translate-y-1/2',
|
||||
'flex h-20 w-20 items-center justify-center',
|
||||
'transition-opacity duration-300 focus:outline-none',
|
||||
isPageNavigationButtonsVisible ? 'top-1/2 opacity-100' : 'bottom-12 opacity-0',
|
||||
isPageNavigationButtonsVisible ? 'top-1/2 z-10 opacity-100' : 'bottom-12 opacity-0',
|
||||
)}
|
||||
aria-label={getLeftPageLabel()}
|
||||
tabIndex={0}
|
||||
@@ -90,7 +90,7 @@ const PageNavigationButtons: React.FC<PageNavigationButtonsProps> = ({
|
||||
'absolute right-2 -translate-y-1/2',
|
||||
'flex h-20 w-20 items-center justify-center',
|
||||
'transition-opacity duration-300 focus:outline-none',
|
||||
isPageNavigationButtonsVisible ? 'top-1/2 opacity-100' : 'bottom-12 opacity-0',
|
||||
isPageNavigationButtonsVisible ? 'top-1/2 z-10 opacity-100' : 'bottom-12 opacity-0',
|
||||
)}
|
||||
aria-label={getRightPageLabel()}
|
||||
tabIndex={0}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
@@ -7,12 +7,10 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { formatNumber, formatProgress } from '@/utils/progress';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
import { SectionItem } from '@/libs/document';
|
||||
import { SIZE_PER_LOC, SIZE_PER_TIME_UNIT } from '@/services/constants';
|
||||
|
||||
interface PageInfoProps {
|
||||
bookKey: string;
|
||||
sections: SectionItem[];
|
||||
horizontalGap: number;
|
||||
contentInsets: Insets;
|
||||
gridInsets: Insets;
|
||||
@@ -20,7 +18,6 @@ interface PageInfoProps {
|
||||
|
||||
const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
bookKey,
|
||||
sections,
|
||||
horizontalGap,
|
||||
contentInsets,
|
||||
gridInsets,
|
||||
@@ -28,7 +25,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getProgress, getViewSettings } = useReaderStore();
|
||||
const { getProgress, getViewSettings, getView } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const progress = getProgress(bookKey);
|
||||
@@ -52,38 +50,28 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
const pageInfo = bookData?.isFixedLayout ? section : pageinfo;
|
||||
const progressInfo = formatProgress(pageInfo?.current, pageInfo?.total, template, localize, lang);
|
||||
|
||||
const activeHref = useMemo(() => progress?.sectionHref || null, [progress?.sectionHref]);
|
||||
const activeSection = useMemo(() => {
|
||||
if (!activeHref) return null;
|
||||
for (const section of sections) {
|
||||
if (section.id === activeHref) return section;
|
||||
const subitem = section.subitems?.find((sub) => sub.id === activeHref);
|
||||
if (subitem) return section;
|
||||
}
|
||||
return null;
|
||||
}, [activeHref, sections]);
|
||||
|
||||
const current = pageInfo?.current || 0;
|
||||
const total = activeSection?.location ? activeSection.location.next : pageInfo?.total || 0;
|
||||
const pages = Math.max(total - current, 0);
|
||||
const timeLeft =
|
||||
total - 1 >= current
|
||||
const { page = 0, pages = 0 } = view?.renderer || {};
|
||||
const current = page;
|
||||
const total = pages;
|
||||
const pagesLeft = Math.max(total - current - 1, 0);
|
||||
const timeLeftStr =
|
||||
total - 1 > current
|
||||
? _('{{time}} min left in chapter', {
|
||||
time: formatNumber(
|
||||
Math.round((pages * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
|
||||
Math.round((pagesLeft * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
|
||||
localize,
|
||||
lang,
|
||||
),
|
||||
})
|
||||
: '';
|
||||
const pageLeft =
|
||||
total - 1 >= current
|
||||
const pagesLeftStr =
|
||||
total - 1 > current
|
||||
? localize
|
||||
? _('{{number}} pages left in chapter', {
|
||||
number: formatNumber(pages, localize, lang),
|
||||
number: formatNumber(pagesLeft, localize, lang),
|
||||
})
|
||||
: _('{{count}} pages left in chapter', {
|
||||
count: pages,
|
||||
count: pagesLeft,
|
||||
})
|
||||
: '';
|
||||
|
||||
@@ -151,8 +139,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
total: total,
|
||||
})
|
||||
: '',
|
||||
timeLeft,
|
||||
pageLeft,
|
||||
timeLeftStr,
|
||||
pagesLeftStr,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
@@ -183,9 +171,9 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
{(progressInfoMode === 'all' || progressInfoMode === 'remaining') && (
|
||||
<>
|
||||
{viewSettings.showRemainingTime ? (
|
||||
<span className='text-start'>{timeLeft}</span>
|
||||
<span className='text-start'>{timeLeftStr}</span>
|
||||
) : viewSettings.showRemainingPages ? (
|
||||
<span className='text-start'>{pageLeft}</span>
|
||||
<span className='text-start'>{pagesLeftStr}</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -224,8 +224,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
return (
|
||||
<div className='reader-content full-height flex'>
|
||||
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
|
||||
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
|
||||
<SideBar />
|
||||
<BooksGrid
|
||||
bookKeys={bookKeys}
|
||||
onCloseBook={handleCloseBook}
|
||||
onGoToLibrary={handleCloseBooksToLibrary}
|
||||
/>
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
|
||||
<Notebook />
|
||||
{showDetailsBook && (
|
||||
|
||||
@@ -237,7 +237,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
isDragging.current = true;
|
||||
// Disable animation during manual drag for immediate feedback
|
||||
setShouldAnimate(false);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
@@ -266,7 +266,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
throttledSave(currentPosition);
|
||||
},
|
||||
[currentPosition, throttledSave],
|
||||
@@ -337,7 +337,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
{/* Vertical ruler */}
|
||||
<div
|
||||
className={clsx(
|
||||
'ruler pointer-events-auto absolute bottom-0 top-0 my-2 cursor-col-resize rounded-2xl',
|
||||
'ruler pointer-events-auto absolute bottom-0 top-0 my-2 cursor-col-resize touch-none rounded-2xl',
|
||||
color === 'transparent' ? 'border-base-content/55 border' : '',
|
||||
)}
|
||||
style={{
|
||||
@@ -357,7 +357,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{/* extended touch area */}
|
||||
<div className='absolute inset-y-0 -left-2 -right-2' />
|
||||
<div className='absolute inset-y-0 -left-2 -right-2 touch-none' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -394,7 +394,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
{/* Horizontal ruler */}
|
||||
<div
|
||||
className={clsx(
|
||||
'ruler pointer-events-auto absolute left-0 right-0 mx-2 cursor-row-resize rounded-2xl',
|
||||
'ruler pointer-events-auto absolute left-0 right-0 mx-2 cursor-row-resize touch-none rounded-2xl',
|
||||
color === 'transparent' ? 'border-base-content/55 border' : '',
|
||||
)}
|
||||
style={{
|
||||
@@ -414,7 +414,7 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{/* Extended touch area */}
|
||||
<div className='absolute inset-x-0 -bottom-2 -top-2' />
|
||||
<div className='absolute inset-x-0 -bottom-2 -top-2 touch-none' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { TbLayoutSidebar, TbLayoutSidebarFilled } from 'react-icons/tb';
|
||||
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import Button from '@/components/Button';
|
||||
@@ -13,7 +12,6 @@ interface SidebarTogglerProps {
|
||||
const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { sideBarBookKey, isSideBarVisible, setSideBarBookKey, toggleSideBar } = useSidebarStore();
|
||||
const { setHoveredBookKey } = useReaderStore();
|
||||
const handleToggleSidebar = () => {
|
||||
if (sideBarBookKey === bookKey) {
|
||||
toggleSideBar();
|
||||
@@ -21,7 +19,6 @@ const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
|
||||
setSideBarBookKey(bookKey);
|
||||
if (!isSideBarVisible) toggleSideBar();
|
||||
}
|
||||
setHoveredBookKey('');
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getStyles } from '@/utils/style';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { formatLocaleDateTime } from '@/utils/book';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
@@ -299,7 +300,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
|
||||
? _('Sign in to Sync')
|
||||
: lastSyncTime
|
||||
? _('Synced at {{time}}', {
|
||||
time: new Date(lastSyncTime).toLocaleString(),
|
||||
time: formatLocaleDateTime(lastSyncTime),
|
||||
})
|
||||
: _('Never synced')
|
||||
}
|
||||
|
||||
@@ -3,23 +3,28 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookNote, HighlightColor } from '@/types/book';
|
||||
import { Point, TextSelection } from '@/utils/sel';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useAnnotationEditor } from '../../hooks/useAnnotationEditor';
|
||||
import { getHighlightColorHex } from '../../utils/annotatorUtil';
|
||||
import MagnifierLoupe from './MagnifierLoupe';
|
||||
|
||||
interface HandleProps {
|
||||
hidden?: boolean;
|
||||
position: Point;
|
||||
isVertical: boolean;
|
||||
type: 'start' | 'end';
|
||||
color: string;
|
||||
onDragStart: () => void;
|
||||
onDragStart: (pointerType: string) => void;
|
||||
onDrag: (point: Point) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
const Handle: React.FC<HandleProps> = ({
|
||||
hidden,
|
||||
position,
|
||||
isVertical,
|
||||
type,
|
||||
@@ -38,7 +43,7 @@ const Handle: React.FC<HandleProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging.current = true;
|
||||
onDragStart();
|
||||
onDragStart(e.pointerType);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[onDragStart],
|
||||
@@ -68,7 +73,10 @@ const Handle: React.FC<HandleProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-auto absolute z-50 cursor-grab touch-none active:cursor-grabbing'
|
||||
className={clsx(
|
||||
'pointer-events-auto absolute z-50 cursor-grab touch-none active:cursor-grabbing',
|
||||
hidden && 'hidden',
|
||||
)}
|
||||
style={{
|
||||
left: isVertical
|
||||
? type === 'start'
|
||||
@@ -143,8 +151,11 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
setSelection,
|
||||
onStartEdit,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const isEink = settings.globalViewSettings.isEink;
|
||||
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
|
||||
const { handlePositions, getHandlePositionsFromRange, handleAnnotationRangeChange } =
|
||||
@@ -153,10 +164,13 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
const initializedRef = useRef(false);
|
||||
const handleColorHex = getHighlightColorHex(settings, handleColor) ?? '#FFFF00';
|
||||
const draggingRef = useRef<'start' | 'end' | null>(null);
|
||||
const dragPointerTypeRef = useRef<string>('');
|
||||
const startRef = useRef<Point>({ x: 0, y: 0 });
|
||||
const endRef = useRef<Point>({ x: 0, y: 0 });
|
||||
const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | null>(null);
|
||||
const [currentStart, setCurrentStart] = useState<Point>({ x: 0, y: 0 });
|
||||
const [currentEnd, setCurrentEnd] = useState<Point>({ x: 0, y: 0 });
|
||||
const [loupePoint, setLoupePoint] = useState<Point | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
@@ -184,19 +198,32 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
endRef.current = handlePositions.end;
|
||||
}, [handlePositions]);
|
||||
|
||||
const handleStartDragStart = useCallback(() => {
|
||||
draggingRef.current = 'start';
|
||||
onStartEdit();
|
||||
}, [onStartEdit]);
|
||||
const handleStartDragStart = useCallback(
|
||||
(pointerType: string) => {
|
||||
draggingRef.current = 'start';
|
||||
dragPointerTypeRef.current = pointerType;
|
||||
setDraggingHandle('start');
|
||||
setLoupePoint({ ...startRef.current });
|
||||
onStartEdit();
|
||||
},
|
||||
[onStartEdit],
|
||||
);
|
||||
|
||||
const handleEndDragStart = useCallback(() => {
|
||||
draggingRef.current = 'end';
|
||||
onStartEdit();
|
||||
}, [onStartEdit]);
|
||||
const handleEndDragStart = useCallback(
|
||||
(pointerType: string) => {
|
||||
draggingRef.current = 'end';
|
||||
dragPointerTypeRef.current = pointerType;
|
||||
setDraggingHandle('end');
|
||||
setLoupePoint({ ...endRef.current });
|
||||
onStartEdit();
|
||||
},
|
||||
[onStartEdit],
|
||||
);
|
||||
|
||||
const handleStartDrag = useCallback(
|
||||
(point: Point) => {
|
||||
setCurrentStart(point);
|
||||
setLoupePoint(point);
|
||||
startRef.current = point;
|
||||
handleAnnotationRangeChange(point, endRef.current, isVertical, true);
|
||||
},
|
||||
@@ -206,6 +233,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
const handleEndDrag = useCallback(
|
||||
(point: Point) => {
|
||||
setCurrentEnd(point);
|
||||
setLoupePoint(point);
|
||||
endRef.current = point;
|
||||
handleAnnotationRangeChange(startRef.current, point, isVertical, true);
|
||||
},
|
||||
@@ -214,6 +242,8 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
draggingRef.current = null;
|
||||
setDraggingHandle(null);
|
||||
setLoupePoint(null);
|
||||
handleAnnotationRangeChange(startRef.current, endRef.current, isVertical, false);
|
||||
}, [isVertical, handleAnnotationRangeChange]);
|
||||
|
||||
@@ -221,9 +251,12 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const showLoupe = loupePoint !== null && appService?.isMobile && !viewSettings?.vertical;
|
||||
|
||||
return (
|
||||
<div className='pointer-events-none fixed inset-0 z-50'>
|
||||
<Handle
|
||||
hidden={draggingHandle === 'end'}
|
||||
position={currentStart}
|
||||
isVertical={isVertical}
|
||||
type='start'
|
||||
@@ -233,6 +266,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
<Handle
|
||||
hidden={draggingHandle === 'start'}
|
||||
position={currentEnd}
|
||||
isVertical={isVertical}
|
||||
type='end'
|
||||
@@ -241,6 +275,14 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
|
||||
onDrag={handleEndDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
{showLoupe && (
|
||||
<MagnifierLoupe
|
||||
bookKey={bookKey}
|
||||
dragPoint={loupePoint}
|
||||
isVertical={isVertical}
|
||||
color={handleColorHex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -318,7 +318,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { writingMode } = defaultView.getComputedStyle(el);
|
||||
draw(Overlayer.bubble, { writingMode });
|
||||
} else if (style === 'highlight') {
|
||||
draw(Overlayer.highlight, { color: isBwEink ? einkBgColor : hexColor });
|
||||
draw(Overlayer.highlight, {
|
||||
color: isBwEink ? einkBgColor : hexColor,
|
||||
vertical: viewSettings.vertical,
|
||||
});
|
||||
} else if (['underline', 'squiggly'].includes(style as string)) {
|
||||
const { defaultView } = doc;
|
||||
const node = range.startContainer;
|
||||
@@ -693,6 +696,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const handleSpeakText = async (oneTime = false) => {
|
||||
if (!selection || !selection.text) return;
|
||||
setShowAnnotPopup(false);
|
||||
setEditingAnnotation(null);
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range, oneTime });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Point } from '@/utils/sel';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface MagnifierLoupeProps {
|
||||
bookKey: string;
|
||||
dragPoint: Point;
|
||||
isVertical: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const MagnifierLoupe: React.FC<MagnifierLoupeProps> = ({
|
||||
bookKey,
|
||||
dragPoint,
|
||||
isVertical,
|
||||
color,
|
||||
}) => {
|
||||
const { getView } = useReaderStore();
|
||||
const radius = useResponsiveSize(52);
|
||||
|
||||
useEffect(() => {
|
||||
const view = getView(bookKey);
|
||||
if (!view) return;
|
||||
view.renderer.showLoupe?.(dragPoint.x, dragPoint.y, { isVertical, color, radius });
|
||||
return () => view.renderer.hideLoupe?.();
|
||||
}, [bookKey, dragPoint, getView, isVertical, color, radius]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default MagnifierLoupe;
|
||||
@@ -93,7 +93,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
errorMsg.innerHTML = _('Unable to load the article. Try searching directly on {{link}}.', {
|
||||
link: `<a href="https://${language}.wikipedia.org/w/index.php?search=${encodeURIComponent(
|
||||
query,
|
||||
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wikipedia</a>`,
|
||||
)}" target="_blank" rel="noopener noreferrer" class="not-eink:text-primary underline">Wikipedia</a>`,
|
||||
});
|
||||
|
||||
errorDiv.append(h1, errorMsg);
|
||||
@@ -120,7 +120,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
<div className='text-base-content flex h-full flex-col pt-2'>
|
||||
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
<div className='not-eink:opacity-60 flex items-center px-4 py-2 text-sm'>
|
||||
Source: Wikipedia (CC BY-SA)
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { MdArrowBack } from 'react-icons/md';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import Popup from '@/components/Popup';
|
||||
@@ -34,8 +35,118 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
onDismiss,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const [lookupWord, setLookupWord] = useState(word);
|
||||
const isLookingUp = useRef(false);
|
||||
const [history, setHistory] = useState<{ items: string[]; index: number }>({
|
||||
items: [word],
|
||||
index: 0,
|
||||
});
|
||||
const lastLookupRef = useRef('');
|
||||
const mainRef = useRef<HTMLElement | null>(null);
|
||||
const footerRef = useRef<HTMLElement | null>(null);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const lastDirectionRef = useRef<'up' | 'down' | null>(null);
|
||||
const scrollDeltaRef = useRef(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [isBackVisible, setIsBackVisible] = useState(false);
|
||||
const lookupWord = history.items[history.index] ?? word;
|
||||
const canGoBack = history.index > 0;
|
||||
const showBackButton = canGoBack && isBackVisible;
|
||||
|
||||
useEffect(() => {
|
||||
setHistory({ items: [word], index: 0 });
|
||||
}, [word]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGoBack) {
|
||||
setIsBackVisible(false);
|
||||
lastScrollTopRef.current = 0;
|
||||
lastDirectionRef.current = null;
|
||||
scrollDeltaRef.current = 0;
|
||||
return;
|
||||
}
|
||||
setIsBackVisible(true);
|
||||
}, [canGoBack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGoBack) return;
|
||||
const main = mainRef.current;
|
||||
if (!main) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafRef.current !== null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
const currentScrollTop = main.scrollTop;
|
||||
const delta = currentScrollTop - lastScrollTopRef.current;
|
||||
if (delta === 0) return;
|
||||
|
||||
if (currentScrollTop <= 4) {
|
||||
setIsBackVisible(true);
|
||||
lastDirectionRef.current = null;
|
||||
scrollDeltaRef.current = 0;
|
||||
lastScrollTopRef.current = currentScrollTop;
|
||||
return;
|
||||
}
|
||||
|
||||
const direction: 'up' | 'down' = delta > 0 ? 'down' : 'up';
|
||||
if (direction !== lastDirectionRef.current) {
|
||||
lastDirectionRef.current = direction;
|
||||
scrollDeltaRef.current = 0;
|
||||
}
|
||||
|
||||
scrollDeltaRef.current += Math.abs(delta);
|
||||
const hideThreshold = 14;
|
||||
const showThreshold = 8;
|
||||
|
||||
if (direction === 'down' && scrollDeltaRef.current >= hideThreshold) {
|
||||
setIsBackVisible(false);
|
||||
scrollDeltaRef.current = 0;
|
||||
} else if (direction === 'up' && scrollDeltaRef.current >= showThreshold) {
|
||||
setIsBackVisible(true);
|
||||
scrollDeltaRef.current = 0;
|
||||
}
|
||||
|
||||
lastScrollTopRef.current = currentScrollTop;
|
||||
});
|
||||
};
|
||||
|
||||
lastScrollTopRef.current = main.scrollTop;
|
||||
main.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
main.removeEventListener('scroll', handleScroll);
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [canGoBack]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsBackVisible(true);
|
||||
lastScrollTopRef.current = 0;
|
||||
lastDirectionRef.current = null;
|
||||
scrollDeltaRef.current = 0;
|
||||
if (mainRef.current) {
|
||||
mainRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [lookupWord]);
|
||||
|
||||
const pushHistory = (nextWord: string) => {
|
||||
const trimmedWord = nextWord.trim();
|
||||
if (!trimmedWord) return;
|
||||
setHistory((prev) => {
|
||||
const currentWord = prev.items[prev.index];
|
||||
if (currentWord === trimmedWord) return prev;
|
||||
const items = [...prev.items.slice(0, prev.index + 1), trimmedWord];
|
||||
return { items, index: items.length - 1 };
|
||||
});
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setHistory((prev) => {
|
||||
if (prev.index === 0) return prev;
|
||||
return { ...prev, index: prev.index - 1 };
|
||||
});
|
||||
};
|
||||
|
||||
const interceptDictLinks = (definition: string): HTMLElement[] => {
|
||||
const container = document.createElement('div');
|
||||
@@ -48,11 +159,10 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
if (title) {
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
setLookupWord(title);
|
||||
isLookingUp.current = false;
|
||||
pushHistory(title);
|
||||
});
|
||||
|
||||
link.className = 'text-primary underline cursor-pointer';
|
||||
link.className = 'not-eink:text-primary underline cursor-pointer';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,12 +170,13 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isLookingUp.current) {
|
||||
return;
|
||||
}
|
||||
isLookingUp.current = true;
|
||||
const main = document.querySelector('main') as HTMLElement;
|
||||
const footer = document.querySelector('footer') as HTMLElement;
|
||||
const langCode = typeof lang === 'string' ? lang : lang?.[0];
|
||||
const lookupKey = `${lookupWord}::${langCode || ''}`;
|
||||
if (lastLookupRef.current === lookupKey) return;
|
||||
lastLookupRef.current = lookupKey;
|
||||
const main = mainRef.current;
|
||||
const footer = footerRef.current;
|
||||
if (!main || !footer) return;
|
||||
|
||||
const fetchDefinitions = async (word: string, language?: string) => {
|
||||
main.innerHTML = '';
|
||||
@@ -95,7 +206,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.innerText = results[0]!.language;
|
||||
p.className = 'text-sm italic opacity-75';
|
||||
p.className = 'text-sm italic not-eink:opacity-75';
|
||||
hgroup.append(h1, p);
|
||||
main.append(hgroup);
|
||||
|
||||
@@ -115,7 +226,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
|
||||
if (examples) {
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'pl-8 list-disc text-sm italic opacity-75';
|
||||
ul.className = 'pl-8 list-disc text-sm italic not-eink:opacity-75';
|
||||
|
||||
examples.forEach((example) => {
|
||||
const exampleLi = document.createElement('li');
|
||||
@@ -150,7 +261,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
p.innerHTML = _('Unable to load the word. Try searching directly on {{link}}.', {
|
||||
link: `<a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(
|
||||
word,
|
||||
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wiktionary</a>`,
|
||||
)}" target="_blank" rel="noopener noreferrer" class="not-eink:text-primary underline">Wiktionary</a>`,
|
||||
});
|
||||
|
||||
div.append(h1, p);
|
||||
@@ -158,8 +269,8 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const langCode = typeof lang === 'string' ? lang : lang?.[0];
|
||||
fetchDefinitions(lookupWord, langCode);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [_, lookupWord, lang]);
|
||||
|
||||
return (
|
||||
@@ -172,10 +283,34 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
className='select-text'
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
<div className='flex h-full flex-col'>
|
||||
<main className='flex-grow overflow-y-auto p-4 font-sans' />
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
<div className='relative flex h-full flex-col'>
|
||||
{canGoBack && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleBack}
|
||||
aria-label={_('Back')}
|
||||
className={`btn btn-ghost btn-circle text-base-content bg-base-200/80 hover:bg-base-200 absolute left-2 top-2 h-8 min-h-8 w-8 p-0 shadow-sm transition-[opacity,transform] duration-200 ease-out ${
|
||||
showBackButton
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'pointer-events-none -translate-y-1 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<MdArrowBack size={18} />
|
||||
</button>
|
||||
)}
|
||||
<main
|
||||
ref={mainRef}
|
||||
className='flex-grow overflow-y-auto px-4 pb-4 font-sans'
|
||||
style={{
|
||||
paddingTop: showBackButton ? 48 : 16,
|
||||
transition: 'padding-top 180ms ease-out',
|
||||
}}
|
||||
/>
|
||||
<footer
|
||||
ref={footerRef}
|
||||
className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'
|
||||
>
|
||||
<div className='not-eink:opacity-60 flex items-center px-4 py-2 text-sm'>
|
||||
Source: Wiktionary (CC BY-SA)
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -250,7 +250,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
)}
|
||||
|
||||
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
|
||||
<RSVPControl bookKey={bookKey} />
|
||||
<RSVPControl bookKey={bookKey} gridInsets={gridInsets} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -182,9 +182,9 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded-full px-3 py-1.5',
|
||||
'bg-base-300 text-base-content',
|
||||
'border-base-content/10 border',
|
||||
'text-base-content flex items-center gap-1 rounded-full px-3 py-1.5',
|
||||
'not-eink:bg-base-300 eink-bordered',
|
||||
'not-eink:border-base-content/10 not-eink:border',
|
||||
'shadow-sm backdrop-blur-md',
|
||||
'transition-all duration-200 ease-out',
|
||||
)}
|
||||
@@ -195,7 +195,7 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
|
||||
className={clsx(
|
||||
'flex items-center justify-center rounded-full p-1.5',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:bg-base-content/10 active:scale-90',
|
||||
'not-eink:hover:bg-base-200 active:scale-90',
|
||||
isLoading && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
title={_('Previous Paragraph')}
|
||||
@@ -238,7 +238,7 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
|
||||
className={clsx(
|
||||
'flex items-center justify-center rounded-full p-1.5',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:bg-base-content/10 active:scale-90',
|
||||
'not-eink:hover:bg-base-200 active:scale-90',
|
||||
isLoading && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
title={_('Next Paragraph')}
|
||||
@@ -253,7 +253,7 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
|
||||
className={clsx(
|
||||
'flex items-center justify-center rounded-full p-1.5',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:bg-base-content/10 active:scale-90',
|
||||
'not-eink:hover:bg-base-200 active:scale-90',
|
||||
isLoading && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
title={_('Exit Paragraph Mode')}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { RSVPController, RsvpStartChoice, RsvpStopPosition } from '@/services/rs
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { Insets } from '@/types/misc';
|
||||
import RSVPOverlay from './RSVPOverlay';
|
||||
import RSVPStartDialog from './RSVPStartDialog';
|
||||
|
||||
interface RSVPControlProps {
|
||||
bookKey: string;
|
||||
gridInsets: Insets;
|
||||
}
|
||||
|
||||
// Helper to expand a range to include the full sentence
|
||||
@@ -99,7 +101,7 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => {
|
||||
return range;
|
||||
};
|
||||
|
||||
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
|
||||
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
|
||||
const _ = useTranslation();
|
||||
const {
|
||||
getView,
|
||||
@@ -461,6 +463,7 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey }) => {
|
||||
portalContainer &&
|
||||
createPortal(
|
||||
<RSVPOverlay
|
||||
gridInsets={gridInsets}
|
||||
controller={controllerRef.current}
|
||||
chapters={chapters}
|
||||
currentChapterHref={currentChapterHref}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { RsvpState, RsvpWord, RSVPController } from '@/services/rsvp';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
IoAdd,
|
||||
} from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
|
||||
interface FlatChapter {
|
||||
label: string;
|
||||
@@ -23,6 +25,7 @@ interface FlatChapter {
|
||||
}
|
||||
|
||||
interface RSVPOverlayProps {
|
||||
gridInsets: Insets;
|
||||
controller: RSVPController;
|
||||
chapters: TOCItem[];
|
||||
currentChapterHref: string | null;
|
||||
@@ -32,6 +35,7 @@ interface RSVPOverlayProps {
|
||||
}
|
||||
|
||||
const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
gridInsets,
|
||||
controller,
|
||||
chapters,
|
||||
currentChapterHref,
|
||||
@@ -293,9 +297,10 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
aria-label={_('Speed Reading')}
|
||||
className='fixed inset-0 z-[10000] flex select-none flex-col'
|
||||
style={{
|
||||
paddingTop: `${gridInsets.top}px`,
|
||||
paddingBottom: `${gridInsets.bottom * 0.33}px`,
|
||||
backgroundColor: bgColor,
|
||||
color: fgColor,
|
||||
// Ensure solid background - no transparency
|
||||
backdropFilter: 'none',
|
||||
// @ts-expect-error CSS custom properties
|
||||
'--rsvp-accent': accentColor,
|
||||
@@ -305,64 +310,66 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className='rsvp-header flex shrink-0 items-center justify-between gap-2 p-3 md:gap-4 md:p-4'>
|
||||
{/* ── Header ── */}
|
||||
<div className='rsvp-header flex shrink-0 items-center gap-2 px-3 py-2 md:gap-3 md:px-5 md:py-3'>
|
||||
<button
|
||||
aria-label={_('Close Speed Reading')}
|
||||
className='flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 md:h-11 md:w-11'
|
||||
onClick={onClose}
|
||||
title={_('Close')}
|
||||
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-gray-500/20 active:scale-95'
|
||||
onClick={onClose}
|
||||
>
|
||||
<IoClose className='h-5 w-5 md:h-6 md:w-6' />
|
||||
<IoClose className='h-5 w-5' />
|
||||
</button>
|
||||
|
||||
{/* Chapter selector */}
|
||||
<div className='relative mx-2 min-w-0 max-w-[200px] flex-1 md:mx-4 md:max-w-[400px]'>
|
||||
<div className='relative min-w-0 flex-1'>
|
||||
<button
|
||||
className='flex w-full cursor-pointer items-center justify-between gap-1 rounded-lg border border-gray-500/30 bg-gray-500/15 px-2 py-1.5 text-xs transition-colors hover:bg-gray-500/25 md:gap-2 md:px-3 md:py-2 md:text-sm'
|
||||
className='flex w-full items-center gap-1.5 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm transition-colors hover:bg-gray-500/20 active:scale-[0.98]'
|
||||
onClick={() => setShowChapterDropdown(!showChapterDropdown)}
|
||||
title={_('Select Chapter')}
|
||||
>
|
||||
<span className='overflow-hidden text-ellipsis whitespace-nowrap'>
|
||||
<span className='min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left'>
|
||||
{getCurrentChapterLabel()}
|
||||
</span>
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
className='shrink-0 md:h-4 md:w-4'
|
||||
strokeWidth='2.5'
|
||||
className='h-3.5 w-3.5 shrink-0 opacity-50'
|
||||
>
|
||||
<path d='M6 9l6 6 6-6' />
|
||||
</svg>
|
||||
</button>
|
||||
{showChapterDropdown && (
|
||||
<div
|
||||
className='absolute left-0 right-0 top-full z-[100] mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-gray-500/30 shadow-lg'
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{flatChapters.map((chapter, idx) => (
|
||||
<button
|
||||
key={`${chapter.href}-${idx}`}
|
||||
className={clsx(
|
||||
'block w-full cursor-pointer border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors hover:bg-gray-500/20',
|
||||
isChapterActive(chapter.href) &&
|
||||
'bg-[color-mix(in_srgb,var(--rsvp-accent)_20%,transparent)] font-semibold',
|
||||
)}
|
||||
style={{ paddingLeft: `${0.75 + chapter.level * 1}rem` }}
|
||||
onClick={() => handleChapterSelect(chapter.href)}
|
||||
>
|
||||
{chapter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<Overlay onDismiss={() => setShowChapterDropdown(false)} />
|
||||
<div
|
||||
className='absolute left-0 right-0 top-full z-[100] mt-1.5 max-h-64 overflow-y-auto rounded-2xl border border-gray-500/20 px-2 shadow-2xl'
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{flatChapters.map((chapter, idx) => (
|
||||
<button
|
||||
key={`${chapter.href}-${idx}`}
|
||||
className={clsx(
|
||||
'block w-full rounded-md border-none bg-transparent px-4 py-2.5 text-left text-sm transition-colors first:rounded-t-2xl last:rounded-b-2xl hover:bg-gray-500/15',
|
||||
isChapterActive(chapter.href) &&
|
||||
'bg-[color-mix(in_srgb,var(--rsvp-accent)_15%,transparent)] font-semibold',
|
||||
)}
|
||||
style={{ paddingLeft: `${1 + chapter.level * 0.875}rem` }}
|
||||
onClick={() => handleChapterSelect(chapter.href)}
|
||||
>
|
||||
{chapter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='shrink-0 text-sm font-medium opacity-70 md:text-base'>
|
||||
{_('{{number}} WPM', { number: state.wpm })}
|
||||
{/* WPM badge */}
|
||||
<div className='shrink-0 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm tabular-nums'>
|
||||
<span className='font-semibold'>{state.wpm}</span>
|
||||
<span className='ml-0.5 text-xs opacity-50'>WPM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useRef, useState } from 'react';
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { BookNote, HighlightColor } from '@/types/book';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
@@ -31,6 +31,9 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
const { getProgress, getView, getViewsById } = useReaderStore();
|
||||
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
|
||||
|
||||
const globalReadSettings = settings.globalReadSettings;
|
||||
const customColors = globalReadSettings.customHighlightColors;
|
||||
|
||||
const { text, cfi, note } = item;
|
||||
const editorRef = useRef<TextEditorRef>(null);
|
||||
const [editorDraft, setEditorDraft] = useState(text || '');
|
||||
@@ -181,10 +184,23 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) =
|
||||
item.note && 'content font-size-xs text-gray-500',
|
||||
(item.style === 'underline' || item.style === 'squiggly') &&
|
||||
'underline decoration-2',
|
||||
item.style === 'highlight' && `bg-${item.color}-500 bg-opacity-40`,
|
||||
item.style === 'underline' && `decoration-${item.color}-400`,
|
||||
item.style === 'squiggly' && `decoration-wavy decoration-${item.color}-400`,
|
||||
item.style === 'highlight' && 'rounded-[4px] px-[2px] py-[1px]',
|
||||
item.style === 'squiggly' && 'decoration-wavy',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...(item.style === 'highlight'
|
||||
? {
|
||||
backgroundColor: `color-mix(in srgb, ${customColors[item.color as HighlightColor] || item.color} calc(var(--overlayer-highlight-opacity, 0.3) * 100%), transparent)`,
|
||||
}
|
||||
: {}),
|
||||
...(item.style === 'underline' || item.style === 'squiggly'
|
||||
? {
|
||||
textDecorationColor: `color-mix(in srgb, ${customColors[item.color as HighlightColor] || item.color} 80%, transparent)`,
|
||||
}
|
||||
: {}),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{text || ''}
|
||||
</span>
|
||||
|
||||
@@ -67,7 +67,7 @@ const ContentNavBar: React.FC<ContentNavBarProps> = ({
|
||||
left: gridInsets.left,
|
||||
}}
|
||||
>
|
||||
<div className='bg-base-100 mx-auto flex w-full items-center justify-center'>
|
||||
<div className='bg-base-100 mx-auto flex w-full items-center justify-center px-4'>
|
||||
{/* Bottom bar: Navigation buttons and Info */}
|
||||
<div className='pointer-events-auto flex h-[52px] max-w-3xl items-center gap-2'>
|
||||
{/* Previous button */}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { GiBookshelf } from 'react-icons/gi';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
@@ -9,15 +8,16 @@ import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import BookMenu from './BookMenu';
|
||||
import SidebarToggler from '../SidebarToggler';
|
||||
|
||||
const SidebarHeader: React.FC<{
|
||||
bookKey: string;
|
||||
isPinned: boolean;
|
||||
isSearchBarVisible: boolean;
|
||||
onGoToLibrary: () => void;
|
||||
onClose: () => void;
|
||||
onTogglePin: () => void;
|
||||
onToggleSearchBar: () => void;
|
||||
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
}> = ({ bookKey, isPinned, isSearchBarVisible, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
const _ = useTranslation();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
@@ -40,13 +40,9 @@ const SidebarHeader: React.FC<{
|
||||
>
|
||||
<MdArrowBackIosNew size={iconSize22} />
|
||||
</button>
|
||||
<button
|
||||
title={_('Go to Library')}
|
||||
className='btn btn-ghost hidden h-8 min-h-8 w-8 p-0 sm:flex'
|
||||
onClick={onGoToLibrary}
|
||||
>
|
||||
<GiBookshelf className='fill-base-content' />
|
||||
</button>
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-w-24 max-w-32 items-center justify-between sm:size-[70%]'>
|
||||
<button
|
||||
|
||||
@@ -26,9 +26,7 @@ const MAX_SIDEBAR_WIDTH = 0.45;
|
||||
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
const SideBar: React.FC<{
|
||||
onGoToLibrary: () => void;
|
||||
}> = ({ onGoToLibrary }) => {
|
||||
const SideBar = ({}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { updateAppTheme, safeAreaInsets } = useThemeStore();
|
||||
@@ -302,9 +300,9 @@ const SideBar: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
<SidebarHeader
|
||||
bookKey={sideBarBookKey!}
|
||||
isPinned={isSideBarPinned}
|
||||
isSearchBarVisible={isSearchBarVisible}
|
||||
onGoToLibrary={onGoToLibrary}
|
||||
onClose={() => setSideBarVisible(false)}
|
||||
onTogglePin={handleSideBarTogglePin}
|
||||
onToggleSearchBar={handleToggleSearchBar}
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useProofreadStore } from '@/store/proofreadStore';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { proofreadTransformer } from '@/services/transformers/proofread';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { TTSController, SILENCE_DATA, TTSMark, TTSHighlightOptions } from '@/services/tts';
|
||||
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
|
||||
import { useTTSControl } from '@/app/reader/hooks/useTTSControl';
|
||||
import { getPopupPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import { fetchImageAsBase64 } from '@/utils/image';
|
||||
import { invokeUseBackgroundAudio } from '@/utils/bridge';
|
||||
import { isCfiInLocation } from '@/utils/cfi';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import Popup from '@/components/Popup';
|
||||
import TTSPanel from './TTSPanel';
|
||||
import TTSIcon from './TTSIcon';
|
||||
@@ -40,688 +26,51 @@ interface TTSControlProps {
|
||||
const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { safeAreaInsets, isDarkMode } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { setViewSettings, setTTSEnabled } = useReaderStore();
|
||||
const { getMergedRules } = useProofreadStore();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const { hoveredBookKey, getViewSettings } = useReaderStore();
|
||||
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const [ttsLang, setTtsLang] = useState<string>('en');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [showIndicator, setShowIndicator] = useState(false);
|
||||
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [showTTSBar, setShowTTSBar] = useState(() => !!viewSettings?.showTTSBar);
|
||||
const [panelPosition, setPanelPosition] = useState<Position>();
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position>();
|
||||
|
||||
const [timeoutOption, setTimeoutOption] = useState(0);
|
||||
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
|
||||
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const backButtonTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [showIndicatorWithinTimeout, setShowIndicatorWithinTimeout] = useState(true);
|
||||
const [showBackToCurrentTTSLocation, setShowBackToCurrentTTSLocation] = useState(false);
|
||||
|
||||
const [shouldMountBackButton, setShouldMountBackButton] = useState(false);
|
||||
const [isBackButtonVisible, setIsBackButtonVisible] = useState(false);
|
||||
const backButtonTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const followingTTSLocationRef = useRef(true);
|
||||
|
||||
const popupPadding = useResponsiveSize(POPUP_PADDING);
|
||||
const maxWidth = window.innerWidth - 2 * popupPadding;
|
||||
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
|
||||
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
|
||||
const [ttsController, setTtsController] = useState<TTSController | null>(null);
|
||||
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
|
||||
const ttsOnRef = useRef(false);
|
||||
|
||||
// this enables WebAudio to play even when the mute toggle switch is ON
|
||||
const unblockAudio = () => {
|
||||
if (unblockerAudioRef.current) return;
|
||||
unblockerAudioRef.current = document.createElement('audio');
|
||||
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
|
||||
unblockerAudioRef.current.addEventListener('play', () => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
}
|
||||
});
|
||||
unblockerAudioRef.current.preload = 'auto';
|
||||
unblockerAudioRef.current.loop = true;
|
||||
unblockerAudioRef.current.src = SILENCE_DATA;
|
||||
unblockerAudioRef.current.play();
|
||||
};
|
||||
|
||||
const releaseUnblockAudio = () => {
|
||||
if (!unblockerAudioRef.current) return;
|
||||
try {
|
||||
unblockerAudioRef.current.pause();
|
||||
unblockerAudioRef.current.currentTime = 0;
|
||||
unblockerAudioRef.current.removeAttribute('src');
|
||||
unblockerAudioRef.current.src = '';
|
||||
unblockerAudioRef.current.load();
|
||||
unblockerAudioRef.current = null;
|
||||
console.log('Unblock audio released');
|
||||
} catch (err) {
|
||||
console.warn('Error releasing unblock audio:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const initMediaSession = async () => {
|
||||
const mediaSession = getMediaSession();
|
||||
if (!mediaSession) return;
|
||||
|
||||
mediaSessionRef.current = mediaSession;
|
||||
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
const bookData = getBookData(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
const { sectionLabel } = progress || {};
|
||||
|
||||
let artworkImage = '/icon.png';
|
||||
try {
|
||||
artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png');
|
||||
} catch {
|
||||
artworkImage = await fetchImageAsBase64('/icon.png');
|
||||
}
|
||||
|
||||
await mediaSession.setActive({
|
||||
active: true,
|
||||
keepAppInForeground: settings.alwaysInForeground,
|
||||
notificationTitle: _('Read Aloud'),
|
||||
notificationText: _('Ready to read aloud'),
|
||||
foregroundServiceTitle: _('Read Aloud'),
|
||||
foregroundServiceText: _('Ready to read aloud'),
|
||||
});
|
||||
mediaSession.updateMetadata({
|
||||
title: title,
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: artworkImage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deinitMediaSession = async () => {
|
||||
if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) {
|
||||
await mediaSessionRef.current.setActive({
|
||||
active: false,
|
||||
keepAppInForeground: settings.alwaysInForeground,
|
||||
});
|
||||
}
|
||||
mediaSessionRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.on('tts-stop', handleTTSStop);
|
||||
return () => {
|
||||
eventDispatcher.off('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.off('tts-stop', handleTTSStop);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ttsController || !bookKey) return;
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
|
||||
const handleNeedAuth = () => {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Please log in to use advanced TTS features'),
|
||||
type: 'error',
|
||||
timeout: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSpeakMark = (e: Event) => {
|
||||
const progress = getProgress(bookKey);
|
||||
const { sectionLabel } = progress || {};
|
||||
const mark = (e as CustomEvent<TTSMark>).detail;
|
||||
|
||||
if (mediaSessionRef.current) {
|
||||
const mediaSession = mediaSessionRef.current;
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
mediaSession.updateMetadata({
|
||||
title: mark?.text || '',
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: '',
|
||||
});
|
||||
} else {
|
||||
mediaSession.metadata = new MediaMetadata({
|
||||
title: mark?.text || '',
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleHighlightMark = (e: Event) => {
|
||||
const { cfi } = (e as CustomEvent<{ cfi: string }>).detail;
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (!cfi || !view || !viewSettings) return;
|
||||
|
||||
viewSettings.ttsLocation = cfi;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
|
||||
if (!followingTTSLocationRef.current) return;
|
||||
|
||||
const docs = view.renderer.getContents();
|
||||
if (docs.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { doc, index: viewSectionIndex } = view.renderer.getContents()[0] as {
|
||||
doc: Document;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
const { anchor, index: ttsSectionIndex } = view.resolveCFI(cfi);
|
||||
if (viewSectionIndex !== ttsSectionIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = anchor(doc);
|
||||
if (!view.renderer.scrolled) {
|
||||
view.renderer.scrollToAnchor(range);
|
||||
} else {
|
||||
const rect = range.getBoundingClientRect();
|
||||
const { start, size, viewSize, sideProp } = view.renderer;
|
||||
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
|
||||
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
|
||||
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
|
||||
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
|
||||
|
||||
const showHeader = viewSettings.showHeader;
|
||||
const showFooter = viewSettings.showFooter;
|
||||
const showBarsOnScroll = viewSettings.showBarsOnScroll;
|
||||
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
|
||||
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
|
||||
const scrollingOverlap = viewSettings.scrollingOverlap;
|
||||
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
|
||||
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
|
||||
if (endInNextView || startInPrevView) {
|
||||
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
|
||||
view.renderer.scrollToAnchor(scrollTo / viewSize);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ttsController.addEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
return () => {
|
||||
ttsController.removeEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ttsController, bookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsLocation = viewSettings?.ttsLocation;
|
||||
const { location } = progress || {};
|
||||
if (!location || !ttsLocation) return;
|
||||
|
||||
const ttsInCurrentLocation = isCfiInLocation(ttsLocation, location);
|
||||
if (ttsInCurrentLocation) {
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
const { doc, index: viewSectionIndex } = view?.renderer.getContents()[0] as {
|
||||
doc: Document;
|
||||
index?: number;
|
||||
};
|
||||
const { anchor, index: ttsSectionIndex } = view?.resolveCFI(ttsLocation) || {};
|
||||
if (viewSectionIndex !== ttsSectionIndex) {
|
||||
return;
|
||||
}
|
||||
const range = anchor?.(doc);
|
||||
if (range) {
|
||||
view?.tts?.highlight(range);
|
||||
}
|
||||
} else {
|
||||
setShowBackToCurrentTTSLocation(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
const handleBackToCurrentTTSLocation = () => {
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsLocation = viewSettings?.ttsLocation;
|
||||
if (!view || !ttsLocation) return;
|
||||
|
||||
const resolved = view.resolveNavigation(ttsLocation);
|
||||
view.renderer.goTo?.(resolved);
|
||||
};
|
||||
|
||||
const getTTSTargetLang = useCallback((): string | null => {
|
||||
const ttsReadAloudText = viewSettings?.ttsReadAloudText;
|
||||
if (viewSettings?.translationEnabled && ttsReadAloudText === 'translated') {
|
||||
return viewSettings?.translateTargetLang || getLocale();
|
||||
} else if (viewSettings?.translationEnabled && ttsReadAloudText === 'source') {
|
||||
const bookData = getBookData(bookKey);
|
||||
return bookData?.book?.primaryLanguage || '';
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
const tts = useTTSControl({
|
||||
bookKey,
|
||||
getBookData,
|
||||
viewSettings?.translationEnabled,
|
||||
viewSettings?.ttsReadAloudText,
|
||||
viewSettings?.translateTargetLang,
|
||||
]);
|
||||
onRequestHidePanel: () => setShowPanel(false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
|
||||
}, [getTTSTargetLang]);
|
||||
|
||||
const transformCtx: TransformContext = useMemo(
|
||||
() => ({
|
||||
bookKey,
|
||||
viewSettings: getViewSettings(bookKey)!,
|
||||
userLocale: getLocale(),
|
||||
content: '',
|
||||
transformers: [],
|
||||
reversePunctuationTransform: true,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const preprocessSSMLForTTS = useCallback(
|
||||
async (ssml: string) => {
|
||||
const rules = getMergedRules(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const ttsOnlyRules = rules.filter(
|
||||
(rule) =>
|
||||
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
|
||||
);
|
||||
if (ttsOnlyRules.length === 0) return ssml;
|
||||
|
||||
transformCtx['content'] = ssml;
|
||||
transformCtx['viewSettings'] = viewSettings;
|
||||
ssml = await proofreadTransformer.transform(transformCtx, {
|
||||
docType: 'text/xml',
|
||||
onlyForTTS: true,
|
||||
});
|
||||
return ssml;
|
||||
},
|
||||
[bookKey, getMergedRules, getViewSettings, transformCtx],
|
||||
);
|
||||
|
||||
const getTTSHighlightOptions = useCallback(
|
||||
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
|
||||
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
|
||||
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
|
||||
return {
|
||||
...ttsHighlightOptions,
|
||||
color,
|
||||
};
|
||||
},
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
|
||||
if (ttsControllerRef.current && ttsHighlightOptions) {
|
||||
ttsControllerRef.current.initViewTTS(
|
||||
getTTSHighlightOptions(ttsHighlightOptions, viewSettings.isEink),
|
||||
);
|
||||
}
|
||||
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
|
||||
|
||||
const handleTTSSpeak = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
|
||||
if (bookKey !== ttsBookKey) return;
|
||||
if (ttsOnRef.current) return;
|
||||
ttsOnRef.current = true;
|
||||
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
|
||||
if (bookData.book?.format === 'PDF') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for PDF'),
|
||||
type: 'warning',
|
||||
});
|
||||
if (tts.showBackToCurrentTTSLocation) {
|
||||
setShouldMountBackButton(true);
|
||||
const fadeInTimeout = setTimeout(() => {
|
||||
setIsBackButtonVisible(true);
|
||||
}, 10);
|
||||
return () => clearTimeout(fadeInTimeout);
|
||||
} else {
|
||||
setIsBackButtonVisible(false);
|
||||
if (backButtonTimeoutRef.current) {
|
||||
clearTimeout(backButtonTimeoutRef.current);
|
||||
}
|
||||
backButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShouldMountBackButton(false);
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
const ttsSpeakRange = range as Range | null;
|
||||
let ttsFromRange = ttsSpeakRange;
|
||||
if (!ttsFromRange && viewSettings.ttsLocation) {
|
||||
const { location } = progress;
|
||||
const ttsCfi = viewSettings.ttsLocation;
|
||||
if (isCfiInLocation(ttsCfi, location)) {
|
||||
const { index, anchor } = view.resolveCFI(ttsCfi);
|
||||
const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
|
||||
if (doc) {
|
||||
ttsFromRange = anchor(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ttsFromRange) {
|
||||
ttsFromRange = progress.range;
|
||||
}
|
||||
|
||||
const primaryLang = bookData.book.primaryLanguage;
|
||||
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.stop();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: true });
|
||||
}
|
||||
if (appService?.isMobile) {
|
||||
unblockAudio();
|
||||
}
|
||||
await initMediaSession();
|
||||
setTtsClientsInitialized(false);
|
||||
|
||||
setShowIndicator(true);
|
||||
const ttsController = new TTSController(appService, view, !!user?.id, preprocessSSMLForTTS);
|
||||
ttsControllerRef.current = ttsController;
|
||||
setTtsController(ttsController);
|
||||
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS(
|
||||
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
|
||||
);
|
||||
const ssml =
|
||||
oneTime && ttsSpeakRange
|
||||
? genSSMLRaw(ttsSpeakRange.toString().trim())
|
||||
: view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
setIsPlaying(true);
|
||||
setTtsLang(lang);
|
||||
|
||||
ttsController.setLang(lang);
|
||||
ttsController.setRate(viewSettings.ttsRate);
|
||||
ttsController.speak(ssml, oneTime, () => handleStop(bookKey));
|
||||
ttsController.setTargetLang(getTTSTargetLang() || '');
|
||||
}
|
||||
setTtsClientsInitialized(true);
|
||||
setTTSEnabled(bookKey, true);
|
||||
} catch (error) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for this document'),
|
||||
type: 'error',
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTTSStop = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey } = event.detail;
|
||||
if (ttsControllerRef.current && bookKey === ttsBookKey) {
|
||||
handleStop(bookKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePlay = useCallback(async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
} else if (isPaused) {
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
// start for forward/backward/setvoice-paused
|
||||
// set rate don't pause the tts
|
||||
if (ttsController.state === 'paused') {
|
||||
await ttsController.resume();
|
||||
} else {
|
||||
await ttsController.start();
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSessionRef.current) {
|
||||
const mediaSession = mediaSessionRef.current;
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
await mediaSession.updatePlaybackState({ playing: !isPlaying });
|
||||
} else {
|
||||
mediaSession.playbackState = isPlaying ? 'paused' : 'playing';
|
||||
}
|
||||
}
|
||||
}, [isPlaying, isPaused]);
|
||||
|
||||
const handleBackward = useCallback(async (byMark = false) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.backward(byMark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleForward = useCallback(async (byMark = false) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.forward(byMark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(
|
||||
async (bookKey: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
setTtsController(null);
|
||||
getView(bookKey)?.deselect();
|
||||
setIsPlaying(false);
|
||||
setShowPanel(false);
|
||||
setShowIndicator(false);
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
}
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: false });
|
||||
}
|
||||
if (appService?.isMobile) {
|
||||
releaseUnblockAudio();
|
||||
}
|
||||
await deinitMediaSession();
|
||||
setTTSEnabled(bookKey, false);
|
||||
ttsOnRef.current = false;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[appService],
|
||||
);
|
||||
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetRate = useCallback(
|
||||
throttle(async (rate: number) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setRate(rate);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setRate(rate);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetVoice = useCallback(
|
||||
throttle(async (voice: string, lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setVoice(voice, lang);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice, lang);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGetVoices = async (lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
return ttsController.getVoices(lang);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleGetVoiceId = () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
return ttsController.getVoiceId();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSelectTimeout = (bookKey: string, value: number) => {
|
||||
setTimeoutOption(value);
|
||||
if (timeoutFunc) {
|
||||
clearTimeout(timeoutFunc);
|
||||
}
|
||||
if (value > 0) {
|
||||
setTimeoutFunc(
|
||||
setTimeout(() => {
|
||||
handleStop(bookKey);
|
||||
}, value * 1000),
|
||||
);
|
||||
setTimeoutTimestamp(Date.now() + value * 1000);
|
||||
} else {
|
||||
setTimeoutTimestamp(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTTSBar = () => {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.showTTSBar = !viewSettings.showTTSBar;
|
||||
setShowTTSBar(viewSettings.showTTSBar);
|
||||
if (viewSettings.showTTSBar) {
|
||||
setShowPanel(false);
|
||||
}
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
};
|
||||
|
||||
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 togglePopup = () => {
|
||||
updatePanelPosition();
|
||||
if (!showPanel && ttsControllerRef.current) {
|
||||
const speakingLang = ttsControllerRef.current.getSpeakingLang() || ttsLang;
|
||||
setTtsLang(speakingLang);
|
||||
}
|
||||
setShowPanel((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
setShowPanel(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { current: mediaSession } = mediaSessionRef;
|
||||
if (mediaSession) {
|
||||
mediaSession.setActionHandler('play', () => {
|
||||
handleTogglePlay();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('pause', () => {
|
||||
handleTogglePlay();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('stop', () => {
|
||||
handlePause();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('seekforward', () => {
|
||||
handleForward(true);
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('seekbackward', () => {
|
||||
handleBackward(true);
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('nexttrack', () => {
|
||||
handleForward();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('previoustrack', () => {
|
||||
handleBackward();
|
||||
});
|
||||
}
|
||||
}, [handleTogglePlay, handlePause, handleForward, handleBackward]);
|
||||
}, [tts.showBackToCurrentTTSLocation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iconRef.current || !showPanel) return;
|
||||
@@ -767,25 +116,47 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
}, [hoveredBookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showBackToCurrentTTSLocation) {
|
||||
followingTTSLocationRef.current = false;
|
||||
setShouldMountBackButton(true);
|
||||
const fadeInTimeout = setTimeout(() => {
|
||||
setIsBackButtonVisible(true);
|
||||
}, 10);
|
||||
return () => clearTimeout(fadeInTimeout);
|
||||
} else {
|
||||
followingTTSLocationRef.current = true;
|
||||
setIsBackButtonVisible(false);
|
||||
if (backButtonTimeoutRef.current) {
|
||||
clearTimeout(backButtonTimeoutRef.current);
|
||||
}
|
||||
backButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShouldMountBackButton(false);
|
||||
}, 300);
|
||||
return;
|
||||
if (tts.showTTSBar) {
|
||||
setShowPanel(false);
|
||||
}
|
||||
}, [showBackToCurrentTTSLocation]);
|
||||
}, [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 togglePopup = () => {
|
||||
updatePanelPosition();
|
||||
if (!showPanel && tts.isTTSActive) {
|
||||
tts.refreshTtsLang();
|
||||
}
|
||||
setShowPanel((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
setShowPanel(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -802,9 +173,9 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleBackToCurrentTTSLocation}
|
||||
onClick={tts.handleBackToCurrentTTSLocation}
|
||||
className={clsx(
|
||||
'bg-base-300 rounded-full px-4 py-2 font-sans text-sm shadow-lg',
|
||||
'not-eink:bg-base-300 eink-bordered rounded-full px-4 py-2 font-sans text-sm shadow-lg',
|
||||
safeAreaInsets?.top ? 'h-11' : 'h-9',
|
||||
)}
|
||||
>
|
||||
@@ -813,7 +184,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
</div>
|
||||
)}
|
||||
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
|
||||
{(showPanel || (showIndicator && showIndicatorWithinTimeout)) && (
|
||||
{(showPanel || (tts.showIndicator && showIndicatorWithinTimeout)) && (
|
||||
<div
|
||||
ref={iconRef}
|
||||
className={clsx(
|
||||
@@ -828,10 +199,14 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
|
||||
<TTSIcon
|
||||
isPlaying={tts.isPlaying}
|
||||
ttsInited={tts.ttsClientsInited}
|
||||
onClick={togglePopup}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPanel && panelPosition && trianglePosition && ttsClientsInited && (
|
||||
{showPanel && panelPosition && trianglePosition && tts.ttsClientsInited && (
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
@@ -842,29 +217,29 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
>
|
||||
<TTSPanel
|
||||
bookKey={bookKey}
|
||||
ttsLang={ttsLang}
|
||||
isPlaying={isPlaying}
|
||||
timeoutOption={timeoutOption}
|
||||
timeoutTimestamp={timeoutTimestamp}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onBackward={handleBackward}
|
||||
onForward={handleForward}
|
||||
onSetRate={handleSetRate}
|
||||
onGetVoices={handleGetVoices}
|
||||
onSetVoice={handleSetVoice}
|
||||
onGetVoiceId={handleGetVoiceId}
|
||||
onSelectTimeout={handleSelectTimeout}
|
||||
onToogleTTSBar={handleToggleTTSBar}
|
||||
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}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
{showIndicator && showTTSBar && ttsClientsInited && (
|
||||
{tts.showIndicator && tts.showTTSBar && tts.ttsClientsInited && (
|
||||
<TTSBar
|
||||
bookKey={bookKey}
|
||||
isPlaying={isPlaying}
|
||||
onBackward={handleBackward}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onForward={handleForward}
|
||||
isPlaying={tts.isPlaying}
|
||||
onBackward={tts.handleBackward}
|
||||
onTogglePlay={tts.handleTogglePlay}
|
||||
onForward={tts.handleForward}
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { Point, TextSelection } from '@/utils/sel';
|
||||
import { Point, TextSelection, snapRangeToWords } from '@/utils/sel';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -127,6 +127,8 @@ export const useAnnotationEditor = ({
|
||||
console.warn('Range is collapsed');
|
||||
return;
|
||||
}
|
||||
|
||||
snapRangeToWords(newRange);
|
||||
} catch (e) {
|
||||
console.warn('Failed to create range:', e);
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { Point, TextSelection } from '@/utils/sel';
|
||||
import { Point, TextSelection, snapRangeToWords } from '@/utils/sel';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
@@ -120,6 +120,8 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
|
||||
if (newRange.collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
snapRangeToWords(newRange);
|
||||
return newRange;
|
||||
} catch (e) {
|
||||
console.warn('Failed to create range:', e);
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useProofreadStore } from '@/store/proofreadStore';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { proofreadTransformer } from '@/services/transformers/proofread';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TTSController, TTSMark, TTSHighlightOptions, TTSVoicesGroup } from '@/services/tts';
|
||||
import { TauriMediaSession } from '@/libs/mediaSession';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { isCfiInLocation } from '@/utils/cfi';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { invokeUseBackgroundAudio } from '@/utils/bridge';
|
||||
import { useTTSMediaSession } from './useTTSMediaSession';
|
||||
|
||||
interface UseTTSControlProps {
|
||||
bookKey: string;
|
||||
onRequestHidePanel?: () => void;
|
||||
}
|
||||
|
||||
export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProps) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { setViewSettings, setTTSEnabled } = useReaderStore();
|
||||
const { getMergedRules } = useProofreadStore();
|
||||
|
||||
const [ttsLang, setTtsLang] = useState<string>('en');
|
||||
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);
|
||||
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
|
||||
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const followingTTSLocationRef = useRef(true);
|
||||
const sectionChangingTimestampRef = useRef(0);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const [ttsController, setTtsController] = useState<TTSController | null>(null);
|
||||
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
|
||||
const ttsOnRef = useRef(false);
|
||||
|
||||
const {
|
||||
mediaSessionRef,
|
||||
unblockAudio,
|
||||
releaseUnblockAudio,
|
||||
initMediaSession,
|
||||
deinitMediaSession,
|
||||
} = useTTSMediaSession({ bookKey });
|
||||
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.on('tts-stop', handleTTSStop);
|
||||
return () => {
|
||||
eventDispatcher.off('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.off('tts-stop', handleTTSStop);
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Controller event listeners (re-registered when ttsController changes)
|
||||
useEffect(() => {
|
||||
if (!ttsController || !bookKey) return;
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
|
||||
const handleNeedAuth = () => {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Please log in to use advanced TTS features'),
|
||||
type: 'error',
|
||||
timeout: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSpeakMark = (e: Event) => {
|
||||
const progress = getProgress(bookKey);
|
||||
const { sectionLabel } = progress || {};
|
||||
const mark = (e as CustomEvent<TTSMark>).detail;
|
||||
|
||||
if (mediaSessionRef.current) {
|
||||
const mediaSession = mediaSessionRef.current;
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
mediaSession.updateMetadata({
|
||||
title: mark?.text || '',
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: '',
|
||||
});
|
||||
} else {
|
||||
mediaSession.metadata = new MediaMetadata({
|
||||
title: mark?.text || '',
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleHighlightMark = (e: Event) => {
|
||||
const { cfi } = (e as CustomEvent<{ cfi: string }>).detail;
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const { location } = progress || {};
|
||||
if (!cfi || !view || !location || !viewSettings) return;
|
||||
|
||||
viewSettings.ttsLocation = cfi;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
|
||||
if (!followingTTSLocationRef.current) return;
|
||||
|
||||
const docs = view.renderer.getContents();
|
||||
if (docs.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { doc, index: viewSectionIndex } = view.renderer.getContents()[0] as {
|
||||
doc: Document;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
const { anchor, index: ttsSectionIndex } = view.resolveCFI(cfi);
|
||||
if (viewSectionIndex !== ttsSectionIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = anchor(doc);
|
||||
if (!view.renderer.scrolled) {
|
||||
view.renderer.scrollToAnchor(range);
|
||||
} else {
|
||||
const rect = range.getBoundingClientRect();
|
||||
const { start, size, viewSize, sideProp } = view.renderer;
|
||||
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
|
||||
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
|
||||
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
|
||||
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
|
||||
|
||||
const showHeader = viewSettings.showHeader;
|
||||
const showFooter = viewSettings.showFooter;
|
||||
const showBarsOnScroll = viewSettings.showBarsOnScroll;
|
||||
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
|
||||
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
|
||||
const scrollingOverlap = viewSettings.scrollingOverlap;
|
||||
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
|
||||
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
|
||||
if (endInNextView || startInPrevView) {
|
||||
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
|
||||
view.renderer.scrollToAnchor(scrollTo / viewSize);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ttsController.addEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
return () => {
|
||||
ttsController.removeEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ttsController, bookKey]);
|
||||
|
||||
// Location tracking — re-highlight when progress changes
|
||||
const progress = getProgress(bookKey);
|
||||
useEffect(() => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsLocation = viewSettings?.ttsLocation;
|
||||
const { location } = progress || {};
|
||||
if (!location || !ttsLocation) return;
|
||||
|
||||
const ttsInCurrentLocation = isCfiInLocation(ttsLocation, location);
|
||||
if (ttsInCurrentLocation) {
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
const range = view?.tts?.getLastRange() as Range | null;
|
||||
if (range) {
|
||||
view?.tts?.highlight(range);
|
||||
}
|
||||
} else {
|
||||
const msSinceSectionChange = Date.now() - sectionChangingTimestampRef.current;
|
||||
if (msSinceSectionChange < 2000) return;
|
||||
setShowBackToCurrentTTSLocation(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
// Location tracking — keep followingTTSLocationRef in sync with showBackToCurrentTTSLocation
|
||||
useEffect(() => {
|
||||
if (showBackToCurrentTTSLocation) {
|
||||
followingTTSLocationRef.current = false;
|
||||
} else {
|
||||
followingTTSLocationRef.current = true;
|
||||
}
|
||||
}, [showBackToCurrentTTSLocation]);
|
||||
|
||||
// Location tracking — handleBackToCurrentTTSLocation
|
||||
const handleBackToCurrentTTSLocation = () => {
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const ttsLocation = viewSettings?.ttsLocation;
|
||||
if (!view || !ttsLocation) return;
|
||||
|
||||
const resolved = view.resolveNavigation(ttsLocation);
|
||||
view.renderer.goTo?.(resolved);
|
||||
};
|
||||
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const getTTSTargetLang = useCallback((): string | null => {
|
||||
const vs = getViewSettings(bookKey);
|
||||
const ttsReadAloudText = vs?.ttsReadAloudText;
|
||||
if (vs?.translationEnabled && ttsReadAloudText === 'translated') {
|
||||
return vs?.translateTargetLang || getLocale();
|
||||
} else if (vs?.translationEnabled && ttsReadAloudText === 'source') {
|
||||
const bookData = getBookData(bookKey);
|
||||
return bookData?.book?.primaryLanguage || '';
|
||||
}
|
||||
return null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
bookKey,
|
||||
getBookData,
|
||||
getViewSettings,
|
||||
viewSettings?.translationEnabled,
|
||||
viewSettings?.ttsReadAloudText,
|
||||
viewSettings?.translateTargetLang,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
|
||||
}, [getTTSTargetLang]);
|
||||
|
||||
// SSML preprocessing
|
||||
const transformCtx: TransformContext = useMemo(
|
||||
() => ({
|
||||
bookKey,
|
||||
viewSettings: getViewSettings(bookKey)!,
|
||||
userLocale: getLocale(),
|
||||
content: '',
|
||||
transformers: [],
|
||||
reversePunctuationTransform: true,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const preprocessSSMLForTTS = useCallback(
|
||||
async (ssml: string) => {
|
||||
const rules = getMergedRules(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const ttsOnlyRules = rules.filter(
|
||||
(rule) =>
|
||||
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
|
||||
);
|
||||
if (ttsOnlyRules.length === 0) return ssml;
|
||||
|
||||
transformCtx['content'] = ssml;
|
||||
transformCtx['viewSettings'] = viewSettings;
|
||||
ssml = await proofreadTransformer.transform(transformCtx, {
|
||||
docType: 'text/xml',
|
||||
onlyForTTS: true,
|
||||
});
|
||||
return ssml;
|
||||
},
|
||||
[bookKey, getMergedRules, getViewSettings, transformCtx],
|
||||
);
|
||||
|
||||
// Section change callback
|
||||
const handleSectionChange = useCallback(
|
||||
async (sectionIndex: number) => {
|
||||
if (!followingTTSLocationRef.current) return;
|
||||
const view = getView(bookKey);
|
||||
const sections = view?.book.sections;
|
||||
if (!sections || sectionIndex < 0 || sectionIndex >= sections.length) return;
|
||||
sectionChangingTimestampRef.current = Date.now();
|
||||
const resolved = view.resolveNavigation(sectionIndex);
|
||||
view.renderer.goTo?.(resolved);
|
||||
},
|
||||
[bookKey, getView],
|
||||
);
|
||||
|
||||
// TTS highlight options
|
||||
const getTTSHighlightOptions = useCallback(
|
||||
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
|
||||
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
|
||||
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
|
||||
return {
|
||||
...ttsHighlightOptions,
|
||||
color,
|
||||
};
|
||||
},
|
||||
[isDarkMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
|
||||
if (ttsControllerRef.current && ttsHighlightOptions) {
|
||||
ttsControllerRef.current.initViewTTS(
|
||||
getTTSHighlightOptions(ttsHighlightOptions, viewSettings!.isEink),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
|
||||
|
||||
// handleStop (defined before handleTTSSpeak/handleTTSStop which reference it)
|
||||
const handleStop = useCallback(
|
||||
async (bookKey: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
setTtsController(null);
|
||||
getView(bookKey)?.deselect();
|
||||
setIsPlaying(false);
|
||||
onRequestHidePanel?.();
|
||||
setShowIndicator(false);
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
}
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: false });
|
||||
}
|
||||
if (appService?.isMobile) {
|
||||
releaseUnblockAudio();
|
||||
}
|
||||
await deinitMediaSession();
|
||||
setTTSEnabled(bookKey, false);
|
||||
ttsOnRef.current = false;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[appService],
|
||||
);
|
||||
|
||||
// handleTTSSpeak / handleTTSStop (plain functions, registered once at mount via closure)
|
||||
const handleTTSSpeak = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
|
||||
if (bookKey !== ttsBookKey) return;
|
||||
if (ttsOnRef.current) return;
|
||||
ttsOnRef.current = true;
|
||||
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
|
||||
if (bookData.book?.format === 'PDF') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for PDF'),
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ttsSpeakRange = range as Range | null;
|
||||
let ttsFromRange = ttsSpeakRange;
|
||||
if (!ttsFromRange && viewSettings.ttsLocation) {
|
||||
const { location } = progress;
|
||||
const ttsCfi = viewSettings.ttsLocation;
|
||||
if (isCfiInLocation(ttsCfi, location)) {
|
||||
const { index, anchor } = view.resolveCFI(ttsCfi);
|
||||
const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
|
||||
if (doc) {
|
||||
ttsFromRange = anchor(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ttsFromRange) {
|
||||
ttsFromRange = progress.range;
|
||||
}
|
||||
|
||||
const currentSection = view.renderer.getContents()[0];
|
||||
if (ttsFromRange && currentSection) {
|
||||
const ttsLocation = view.getCFI(currentSection?.index || 0, ttsFromRange);
|
||||
viewSettings.ttsLocation = ttsLocation;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
|
||||
const primaryLang = bookData.book.primaryLanguage;
|
||||
|
||||
if (ttsControllerRef.current) {
|
||||
ttsControllerRef.current.stop();
|
||||
ttsControllerRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: true });
|
||||
}
|
||||
if (appService?.isMobile) {
|
||||
unblockAudio();
|
||||
}
|
||||
await initMediaSession();
|
||||
setTtsClientsInitialized(false);
|
||||
|
||||
setShowIndicator(true);
|
||||
const ttsController = new TTSController(
|
||||
appService,
|
||||
view,
|
||||
!!user?.id,
|
||||
preprocessSSMLForTTS,
|
||||
handleSectionChange,
|
||||
);
|
||||
ttsControllerRef.current = ttsController;
|
||||
setTtsController(ttsController);
|
||||
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS(
|
||||
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
|
||||
);
|
||||
const ssml =
|
||||
oneTime && ttsSpeakRange
|
||||
? genSSMLRaw(ttsSpeakRange.toString().trim())
|
||||
: view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
setIsPlaying(true);
|
||||
setTtsLang(lang);
|
||||
|
||||
ttsController.setLang(lang);
|
||||
ttsController.setRate(viewSettings.ttsRate);
|
||||
ttsController.speak(ssml, oneTime, () => handleStop(bookKey));
|
||||
ttsController.setTargetLang(getTTSTargetLang() || '');
|
||||
}
|
||||
setTtsClientsInitialized(true);
|
||||
setTTSEnabled(bookKey, true);
|
||||
} catch (error) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('TTS not supported for this document'),
|
||||
type: 'error',
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTTSStop = async (event: CustomEvent) => {
|
||||
const { bookKey: ttsBookKey } = event.detail;
|
||||
if (ttsControllerRef.current && bookKey === ttsBookKey) {
|
||||
handleStop(bookKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Playback callbacks
|
||||
const handleTogglePlay = useCallback(async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (!ttsController) return;
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
} else if (isPaused) {
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
// start for forward/backward/setvoice-paused
|
||||
// set rate don't pause the tts
|
||||
if (ttsController.state === 'paused') {
|
||||
await ttsController.resume();
|
||||
} else {
|
||||
await ttsController.start();
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSessionRef.current) {
|
||||
const mediaSession = mediaSessionRef.current;
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
await mediaSession.updatePlaybackState({ playing: !isPlaying });
|
||||
} else {
|
||||
mediaSession.playbackState = isPlaying ? 'paused' : 'playing';
|
||||
}
|
||||
}
|
||||
}, [isPlaying, isPaused, mediaSessionRef]);
|
||||
|
||||
const handleBackward = useCallback(async (byMark = false) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.backward(byMark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleForward = useCallback(async (byMark = false) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.forward(byMark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(async () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(true);
|
||||
await ttsController.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Rate/voice/timeout/bar controls
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetRate = useCallback(
|
||||
throttle(async (rate: number) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setRate(rate);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setRate(rate);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetVoice = useCallback(
|
||||
throttle(async (voice: string, lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setVoice(voice, lang);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice, lang);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGetVoices = async (lang: string): Promise<TTSVoicesGroup[]> => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
return ttsController.getVoices(lang);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleGetVoiceId = () => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
return ttsController.getVoiceId();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSelectTimeout = (bookKey: string, value: number) => {
|
||||
setTimeoutOption(value);
|
||||
if (timeoutFunc) {
|
||||
clearTimeout(timeoutFunc);
|
||||
}
|
||||
if (value > 0) {
|
||||
setTimeoutFunc(
|
||||
setTimeout(() => {
|
||||
handleStop(bookKey);
|
||||
}, value * 1000),
|
||||
);
|
||||
setTimeoutTimestamp(Date.now() + value * 1000);
|
||||
} else {
|
||||
setTimeoutTimestamp(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) {
|
||||
setTtsLang(speakingLang);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Media session action handler effect
|
||||
useEffect(() => {
|
||||
const { current: mediaSession } = mediaSessionRef;
|
||||
if (mediaSession) {
|
||||
mediaSession.setActionHandler('play', () => {
|
||||
handleTogglePlay();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('pause', () => {
|
||||
handleTogglePlay();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('stop', () => {
|
||||
handlePause();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('seekforward', () => {
|
||||
handleForward(true);
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('seekbackward', () => {
|
||||
handleBackward(true);
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('nexttrack', () => {
|
||||
handleForward();
|
||||
});
|
||||
|
||||
mediaSession.setActionHandler('previoustrack', () => {
|
||||
handleBackward();
|
||||
});
|
||||
}
|
||||
}, [handleTogglePlay, handlePause, handleForward, handleBackward, mediaSessionRef]);
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
isPaused,
|
||||
ttsLang,
|
||||
ttsClientsInited,
|
||||
isTTSActive: ttsController !== null,
|
||||
showIndicator,
|
||||
showTTSBar,
|
||||
showBackToCurrentTTSLocation,
|
||||
timeoutOption,
|
||||
timeoutTimestamp,
|
||||
handleTogglePlay,
|
||||
handleBackward,
|
||||
handleForward,
|
||||
handlePause,
|
||||
handleSetRate,
|
||||
handleSetVoice,
|
||||
handleGetVoices,
|
||||
handleGetVoiceId,
|
||||
handleSelectTimeout,
|
||||
handleToggleTTSBar,
|
||||
handleBackToCurrentTTSLocation,
|
||||
refreshTtsLang,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useRef } from 'react';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { SILENCE_DATA } from '@/services/tts';
|
||||
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
|
||||
import { fetchImageAsBase64 } from '@/utils/image';
|
||||
|
||||
interface UseTTSMediaSessionProps {
|
||||
bookKey: string;
|
||||
}
|
||||
|
||||
export const useTTSMediaSession = ({ bookKey }: UseTTSMediaSessionProps) => {
|
||||
const _ = useTranslation();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getProgress } = useReaderStore();
|
||||
|
||||
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
|
||||
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// this enables WebAudio to play even when the mute toggle switch is ON
|
||||
const unblockAudio = () => {
|
||||
if (unblockerAudioRef.current) return;
|
||||
unblockerAudioRef.current = document.createElement('audio');
|
||||
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
|
||||
unblockerAudioRef.current.addEventListener('play', () => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
}
|
||||
});
|
||||
unblockerAudioRef.current.preload = 'auto';
|
||||
unblockerAudioRef.current.loop = true;
|
||||
unblockerAudioRef.current.src = SILENCE_DATA;
|
||||
unblockerAudioRef.current.play();
|
||||
};
|
||||
|
||||
const releaseUnblockAudio = () => {
|
||||
if (!unblockerAudioRef.current) return;
|
||||
try {
|
||||
unblockerAudioRef.current.pause();
|
||||
unblockerAudioRef.current.currentTime = 0;
|
||||
unblockerAudioRef.current.removeAttribute('src');
|
||||
unblockerAudioRef.current.src = '';
|
||||
unblockerAudioRef.current.load();
|
||||
unblockerAudioRef.current = null;
|
||||
console.log('Unblock audio released');
|
||||
} catch (err) {
|
||||
console.warn('Error releasing unblock audio:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const initMediaSession = async () => {
|
||||
const mediaSession = getMediaSession();
|
||||
if (!mediaSession) return;
|
||||
|
||||
mediaSessionRef.current = mediaSession;
|
||||
|
||||
if (mediaSession instanceof TauriMediaSession) {
|
||||
const bookData = getBookData(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
const { sectionLabel } = progress || {};
|
||||
|
||||
let artworkImage = '/icon.png';
|
||||
try {
|
||||
artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png');
|
||||
} catch {
|
||||
artworkImage = await fetchImageAsBase64('/icon.png');
|
||||
}
|
||||
|
||||
await mediaSession.setActive({
|
||||
active: true,
|
||||
keepAppInForeground: settings.alwaysInForeground,
|
||||
notificationTitle: _('Read Aloud'),
|
||||
notificationText: _('Ready to read aloud'),
|
||||
foregroundServiceTitle: _('Read Aloud'),
|
||||
foregroundServiceText: _('Ready to read aloud'),
|
||||
});
|
||||
mediaSession.updateMetadata({
|
||||
title: title,
|
||||
artist: sectionLabel || title,
|
||||
album: author,
|
||||
artwork: artworkImage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deinitMediaSession = async () => {
|
||||
if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) {
|
||||
await mediaSessionRef.current.setActive({
|
||||
active: false,
|
||||
keepAppInForeground: settings.alwaysInForeground,
|
||||
});
|
||||
}
|
||||
mediaSessionRef.current = null;
|
||||
};
|
||||
|
||||
return {
|
||||
mediaSessionRef,
|
||||
unblockerAudioRef,
|
||||
unblockAudio,
|
||||
releaseUnblockAudio,
|
||||
initMediaSession,
|
||||
deinitMediaSession,
|
||||
};
|
||||
};
|
||||
@@ -147,7 +147,6 @@ export const useTextSelector = (
|
||||
makeSelection(sel, index, true);
|
||||
} else if (appService?.isAndroidApp) {
|
||||
isUpToPopup.current = false;
|
||||
makeSelection(sel, index, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,11 +6,15 @@ import {
|
||||
MdOutlineDelete,
|
||||
MdOutlineEdit,
|
||||
MdSaveAlt,
|
||||
MdExpandMore,
|
||||
MdExpandLess,
|
||||
} from 'react-icons/md';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import {
|
||||
formatAuthors,
|
||||
formatDate,
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
formatPublisher,
|
||||
formatTitle,
|
||||
} from '@/utils/book';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import BookCover from '@/components/BookCover';
|
||||
import Dropdown from '../Dropdown';
|
||||
import MenuItem from '../MenuItem';
|
||||
@@ -49,6 +54,24 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
|
||||
onExport,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
const toggleSeriesCollapse = () => {
|
||||
saveSysSettings(envConfig, 'metadataSeriesCollapsed', !settings.metadataSeriesCollapsed);
|
||||
};
|
||||
|
||||
const toggleOthersCollapse = () => {
|
||||
saveSysSettings(envConfig, 'metadataOthersCollapsed', !settings.metadataOthersCollapsed);
|
||||
};
|
||||
|
||||
const toggleDescriptionCollapse = () => {
|
||||
saveSysSettings(
|
||||
envConfig,
|
||||
'metadataDescriptionCollapsed',
|
||||
!settings.metadataDescriptionCollapsed,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative w-full rounded-lg'>
|
||||
@@ -131,56 +154,141 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className='text-base-content my-4'>
|
||||
<div className='mb-4 grid grid-cols-2 gap-4 sm:grid-cols-3'>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Publisher')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatPublisher(metadata?.publisher || '') || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Published')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatDate(metadata?.published, true) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Updated')}</span>
|
||||
<p className='text-neutral-content text-sm'>{formatDate(book.updatedAt) || ''}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Added')}</span>
|
||||
<p className='text-neutral-content text-sm'>{formatDate(book.createdAt) || ''}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Language')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatLanguage(metadata?.language) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Subjects')}</span>
|
||||
<p className='text-neutral-content line-clamp-3 text-sm'>
|
||||
{formatAuthors(metadata?.subject || '') || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Format')}</span>
|
||||
<p className='text-neutral-content text-sm'>{book.format || _('Unknown')}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('File Size')}</span>
|
||||
<p className='text-neutral-content text-sm'>{formatBytes(fileSize) || _('Unknown')}</p>
|
||||
</div>
|
||||
<div className='metadata-others'>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-between px-4 py-3 text-left transition-colors',
|
||||
settings.metadataOthersCollapsed ? 'hover:bg-base-200 rounded-lg' : '',
|
||||
)}
|
||||
onClick={toggleOthersCollapse}
|
||||
>
|
||||
<span className='text-neutral-content/85 text-base font-semibold'>{_('Metadata')}</span>
|
||||
<div className='transition-transform duration-200'>
|
||||
{settings.metadataOthersCollapsed ? (
|
||||
<MdExpandMore className='h-5 w-5' />
|
||||
) : (
|
||||
<MdExpandLess className='h-5 w-5' />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{!settings.metadataOthersCollapsed && (
|
||||
<div className='px-4 py-1'>
|
||||
<div className='grid grid-cols-2 gap-4 sm:grid-cols-3'>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Publisher')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatPublisher(metadata?.publisher || '') || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Published')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatDate(metadata?.published, true) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Updated')}</span>
|
||||
<p className='text-neutral-content text-sm'>{formatDate(book.updatedAt) || ''}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Added')}</span>
|
||||
<p className='text-neutral-content text-sm'>{formatDate(book.createdAt) || ''}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Language')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatLanguage(metadata?.language) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Subjects')}</span>
|
||||
<p className='text-neutral-content line-clamp-3 text-sm'>
|
||||
{formatAuthors(metadata?.subject || '') || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Format')}</span>
|
||||
<p className='text-neutral-content text-sm'>{book.format || _('Unknown')}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('File Size')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatBytes(fileSize) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Identifier')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
{metadata?.identifier || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-bold'>{_('Description')}</span>
|
||||
<p
|
||||
className='text-neutral-content prose prose-sm max-w-full whitespace-pre-line text-sm'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: metadata?.description || _('No description available'),
|
||||
}}
|
||||
></p>
|
||||
<div className='metadata-series'>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-between px-4 py-3 text-left transition-colors',
|
||||
settings.metadataSeriesCollapsed ? 'hover:bg-base-200 rounded-lg' : '',
|
||||
)}
|
||||
onClick={toggleSeriesCollapse}
|
||||
>
|
||||
<span className='text-neutral-content/85 text-base font-semibold'>{_('Series')}</span>
|
||||
<div className='transition-transform duration-200'>
|
||||
{settings.metadataSeriesCollapsed ? (
|
||||
<MdExpandMore className='h-5 w-5' />
|
||||
) : (
|
||||
<MdExpandLess className='h-5 w-5' />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{!settings.metadataSeriesCollapsed && (
|
||||
<div className='px-4 py-1'>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='col-span-2 overflow-hidden'>
|
||||
<span className='font-bold'>{_('Series')}</span>
|
||||
<p className='text-neutral-content text-sm'>{metadata?.series || _('Unknown')}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden pe-1 text-end'>
|
||||
<span className='font-bold'>{_('Series Index')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{metadata?.seriesIndex || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='metadata-description'>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-between rounded-lg px-4 py-3 text-left transition-colors',
|
||||
settings.metadataDescriptionCollapsed ? 'hover:bg-base-200' : '',
|
||||
)}
|
||||
onClick={toggleDescriptionCollapse}
|
||||
>
|
||||
<span className='text-neutral-content/85 text-base font-semibold'>
|
||||
{_('Description')}
|
||||
</span>
|
||||
<div className='transition-transform duration-200'>
|
||||
{settings.metadataDescriptionCollapsed ? (
|
||||
<MdExpandMore className='h-5 w-5' />
|
||||
) : (
|
||||
<MdExpandLess className='h-5 w-5' />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{!settings.metadataDescriptionCollapsed && (
|
||||
<div className='px-4 py-1'>
|
||||
<p
|
||||
className='text-neutral-content prose prose-sm max-w-full whitespace-pre-line text-sm'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: metadata?.description || _('No description available'),
|
||||
}}
|
||||
></p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useResetViewSettings } from '@/hooks/useResetSettings';
|
||||
import { TRANSLATED_LANGS, TRANSLATOR_LANGS } from '@/services/constants';
|
||||
import { ConvertChineseVariant } from '@/types/book';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import { getDirFromLanguage } from '@/utils/rtl';
|
||||
import { isCJKEnv } from '@/utils/misc';
|
||||
import Select from '@/components/Select';
|
||||
|
||||
@@ -142,8 +143,11 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
|
||||
useEffect(() => {
|
||||
if (uiLanguage === viewSettings.uiLanguage) return;
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', uiLanguage, false, false);
|
||||
const sameDir = getDirFromLanguage(uiLanguage) === getDirFromLanguage(viewSettings.uiLanguage);
|
||||
applyUILanguage(uiLanguage);
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', uiLanguage, false, false).then(() => {
|
||||
if (!sameDir) window.location.reload();
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uiLanguage]);
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ export function useGamepad(config: GamepadConfig = {}) {
|
||||
|
||||
const onDisconnect = (e: GamepadEvent) => {
|
||||
console.log('Gamepad disconnected:', e.gamepad.id);
|
||||
const gamepads = navigator.getGamepads();
|
||||
const gamepads = navigator.getGamepads?.() || [];
|
||||
if (!gamepads.some((g) => g?.connected)) {
|
||||
stopPolling();
|
||||
}
|
||||
@@ -183,8 +183,7 @@ export function useGamepad(config: GamepadConfig = {}) {
|
||||
window.addEventListener('gamepadconnected', onConnect);
|
||||
window.addEventListener('gamepaddisconnected', onDisconnect);
|
||||
|
||||
// Check if gamepad already connected
|
||||
const gamepads = navigator.getGamepads();
|
||||
const gamepads = navigator.getGamepads?.() || [];
|
||||
if (gamepads.some((g) => g?.connected)) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const useTrafficLight = () => {
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
setTrafficLightVisibility(true, { x: 10, y: 20 });
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookFormat } from '@/types/book';
|
||||
import { Contributor, Identifier, LanguageMap } from '@/utils/book';
|
||||
import { Collection, Contributor, Identifier, LanguageMap } from '@/utils/book';
|
||||
import * as epubcfi from 'foliate-js/epubcfi.js';
|
||||
|
||||
export const CFI = epubcfi;
|
||||
@@ -47,6 +47,10 @@ export type BookMetadata = {
|
||||
subject?: string | string[] | Contributor;
|
||||
identifier?: string;
|
||||
altIdentifier?: string | string[] | Identifier;
|
||||
belongsTo?: {
|
||||
collection?: Array<Collection> | Collection;
|
||||
series?: Array<Collection> | Collection;
|
||||
};
|
||||
|
||||
subtitle?: string;
|
||||
series?: string;
|
||||
|
||||
@@ -408,12 +408,22 @@ export abstract class BaseAppService implements AppService {
|
||||
sourceTitle: formatTitle(loadedBook.metadata.title),
|
||||
primaryLanguage,
|
||||
author: formatAuthors(loadedBook.metadata.author, primaryLanguage),
|
||||
metadata: loadedBook.metadata,
|
||||
createdAt: existingBook ? existingBook.createdAt : Date.now(),
|
||||
uploadedAt: existingBook ? existingBook.uploadedAt : null,
|
||||
deletedAt: transient ? Date.now() : null,
|
||||
downloadedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
// update series info from metadata
|
||||
if (book.metadata?.belongsTo?.series) {
|
||||
const belongsTo = book.metadata.belongsTo.series;
|
||||
const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
|
||||
if (series) {
|
||||
book.metadata.series = formatTitle(series.name);
|
||||
book.metadata.seriesIndex = parseFloat(series.position || '0');
|
||||
}
|
||||
}
|
||||
// update book metadata when reimporting the same book
|
||||
if (existingBook) {
|
||||
existingBook.format = book.format;
|
||||
@@ -421,6 +431,7 @@ export abstract class BaseAppService implements AppService {
|
||||
existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle;
|
||||
existingBook.author = existingBook.author ?? book.author;
|
||||
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
|
||||
existingBook.metadata = book.metadata;
|
||||
existingBook.downloadedAt = Date.now();
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
libraryAutoColumns: true,
|
||||
libraryColumns: 6,
|
||||
|
||||
metadataSeriesCollapsed: false,
|
||||
metadataOthersCollapsed: false,
|
||||
metadataDescriptionCollapsed: false,
|
||||
|
||||
kosync: DEFAULT_KOSYNC_SETTINGS,
|
||||
aiSettings: DEFAULT_AI_SETTINGS,
|
||||
|
||||
@@ -784,6 +788,7 @@ export const TRANSLATED_LANGS = {
|
||||
es: 'Español',
|
||||
pt: 'Português',
|
||||
ru: 'Русский',
|
||||
he: 'עברית',
|
||||
ar: 'العربية',
|
||||
fa: 'فارسی',
|
||||
el: 'Ελληνικά',
|
||||
|
||||
@@ -146,12 +146,7 @@ export class EdgeTTSClient implements TTSClient {
|
||||
} as TTSMessageEvent;
|
||||
|
||||
const result = await new Promise<TTSMessageEvent>((resolve) => {
|
||||
let safetyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const cleanUp = () => {
|
||||
if (safetyTimeoutId) {
|
||||
clearTimeout(safetyTimeoutId);
|
||||
safetyTimeoutId = null;
|
||||
}
|
||||
audio.onended = null;
|
||||
audio.onerror = null;
|
||||
audio.src = '';
|
||||
@@ -175,13 +170,6 @@ export class EdgeTTSClient implements TTSClient {
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
}
|
||||
audio.onended = handleEnded;
|
||||
audio.ontimeupdate = () => {
|
||||
if (!safetyTimeoutId && audio.duration && isFinite(audio.duration)) {
|
||||
const remainingTime = (audio.duration - audio.currentTime) / this.#rate;
|
||||
const safetyMargin = 0.5;
|
||||
safetyTimeoutId = setTimeout(handleEnded, (remainingTime + safetyMargin) * 1000);
|
||||
}
|
||||
};
|
||||
audio.onerror = (e) => {
|
||||
cleanUp();
|
||||
console.warn('Audio playback error:', e);
|
||||
|
||||
@@ -27,6 +27,7 @@ export class TTSController extends EventTarget {
|
||||
view: FoliateView;
|
||||
isAuthenticated: boolean = false;
|
||||
preprocessCallback?: (ssml: string) => Promise<string>;
|
||||
onSectionChange?: (sectionIndex: number) => Promise<void>;
|
||||
#nossmlCnt: number = 0;
|
||||
#currentSpeakAbortController: AbortController | null = null;
|
||||
#currentSpeakPromise: Promise<void> | null = null;
|
||||
@@ -52,6 +53,7 @@ export class TTSController extends EventTarget {
|
||||
view: FoliateView,
|
||||
isAuthenticated: boolean = false,
|
||||
preprocessCallback?: (ssml: string) => Promise<string>,
|
||||
onSectionChange?: (sectionIndex: number) => Promise<void>,
|
||||
) {
|
||||
super();
|
||||
this.ttsWebClient = new WebSpeechClient(this);
|
||||
@@ -65,6 +67,7 @@ export class TTSController extends EventTarget {
|
||||
this.view = view;
|
||||
this.isAuthenticated = isAuthenticated;
|
||||
this.preprocessCallback = preprocessCallback;
|
||||
this.onSectionChange = onSectionChange;
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -130,9 +133,8 @@ export class TTSController extends EventTarget {
|
||||
}
|
||||
|
||||
async #initTTSForSection(sectionIndex: number): Promise<boolean> {
|
||||
const currentSection = this.view.renderer.getContents()[0];
|
||||
const sections = this.view.book.sections;
|
||||
if (!sections || sectionIndex < 0 || sectionIndex >= sections.length || !currentSection) {
|
||||
if (!sections || sectionIndex < 0 || sectionIndex >= sections.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -143,6 +145,11 @@ export class TTSController extends EventTarget {
|
||||
|
||||
this.#ttsSectionIndex = sectionIndex;
|
||||
|
||||
const currentSection = this.view.renderer.getContents()[0];
|
||||
if (currentSection?.index !== sectionIndex) {
|
||||
await this.onSectionChange?.(sectionIndex);
|
||||
}
|
||||
|
||||
let doc: Document;
|
||||
if (currentSection?.index === sectionIndex && currentSection?.doc) {
|
||||
doc = currentSection.doc;
|
||||
|
||||
@@ -180,6 +180,16 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
|
||||
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
|
||||
book.metadata = book.metadata ?? bookDoc.metadata;
|
||||
|
||||
// Update series info from metadata
|
||||
if (bookDoc.metadata.belongsTo?.series) {
|
||||
const belongsTo = bookDoc.metadata.belongsTo.series;
|
||||
const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
|
||||
if (series) {
|
||||
book.metadata.series = formatTitle(series.name);
|
||||
book.metadata.seriesIndex = parseFloat(series.position || '0');
|
||||
}
|
||||
}
|
||||
// TODO: uncomment this when we can ensure metaHash is correctly generated for all books
|
||||
// book.metaHash = book.metaHash ?? getMetadataHash(bookDoc.metadata);
|
||||
book.metaHash = getMetadataHash(bookDoc.metadata);
|
||||
|
||||
@@ -13,6 +13,7 @@ export const LibrarySortByType = {
|
||||
Author: 'author',
|
||||
Updated: 'updated',
|
||||
Created: 'created',
|
||||
Series: 'series',
|
||||
Size: 'size',
|
||||
Format: 'format',
|
||||
Published: 'published',
|
||||
@@ -95,6 +96,9 @@ export interface SystemSettings {
|
||||
customFonts: CustomFont[];
|
||||
customTextures: CustomTexture[];
|
||||
opdsCatalogs: OPDSCatalog[];
|
||||
metadataSeriesCollapsed: boolean;
|
||||
metadataOthersCollapsed: boolean;
|
||||
metadataDescriptionCollapsed: boolean;
|
||||
|
||||
kosync: KOSyncSettings;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface FoliateView extends HTMLElement {
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
resolveCFI: (cfi: string) => { index: number; anchor: RangeAnchor };
|
||||
resolveNavigation: (cfiOrHref: string) => { index: number; anchor?: RangeAnchor };
|
||||
resolveNavigation: (cfiOrHrefOrIndex: string | number) => { index: number; anchor?: RangeAnchor };
|
||||
addAnnotation: (
|
||||
note: BookNote & { value?: string },
|
||||
remove?: boolean,
|
||||
@@ -79,6 +79,12 @@ export interface FoliateView extends HTMLElement {
|
||||
option?: AddEventListenerOptions,
|
||||
) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
showLoupe?: (
|
||||
x: number,
|
||||
y: number,
|
||||
options: { isVertical: boolean; color: string; radius: number },
|
||||
) => void;
|
||||
hideLoupe?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BookMetadata, EXTS } from '@/libs/document';
|
||||
import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
|
||||
import { SUPPORTED_LANGS } from '@/services/constants';
|
||||
import { getUserLang, makeSafeFilename } from './misc';
|
||||
import { getLocale, getUserLang, makeSafeFilename } from './misc';
|
||||
import { getStorageType } from './storage';
|
||||
import { getDirFromLanguage } from './rtl';
|
||||
import { code6392to6391, isValidLang, normalizedLangCode } from './lang';
|
||||
@@ -56,6 +56,11 @@ export interface Contributor {
|
||||
name: LanguageMap;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
name: string;
|
||||
position?: string;
|
||||
}
|
||||
|
||||
const formatLanguageMap = (x: string | LanguageMap, defaultLang = false): string => {
|
||||
const userLang = getUserLang();
|
||||
if (!x) return '';
|
||||
@@ -181,6 +186,11 @@ export const formatDate = (date: string | number | Date | null | undefined, isUT
|
||||
}
|
||||
};
|
||||
|
||||
export const formatLocaleDateTime = (date: number | Date) => {
|
||||
const userLang = getLocale();
|
||||
return new Date(date).toLocaleString(userLang);
|
||||
};
|
||||
|
||||
export const formatBytes = (bytes?: number | null, locale = 'en-US') => {
|
||||
if (!bytes) return '';
|
||||
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'];
|
||||
|
||||
@@ -220,6 +220,53 @@ export const getPopupPosition = (
|
||||
return { point: popupPoint, dir: position.dir } as Position;
|
||||
};
|
||||
|
||||
export const snapRangeToWords = (range: Range): void => {
|
||||
if (typeof Intl === 'undefined' || !Intl.Segmenter) return;
|
||||
|
||||
const isPunctuation = (ch: string) => /^\p{P}|\p{S}$/u.test(ch);
|
||||
|
||||
const snapStartToWordBoundary = () => {
|
||||
const node = range.startContainer;
|
||||
if (node.nodeType !== Node.TEXT_NODE) return;
|
||||
const text = node.textContent ?? '';
|
||||
const offset = range.startOffset;
|
||||
if (offset === 0 || offset >= text.length) return;
|
||||
|
||||
const charAtOffset = text[offset] ?? '';
|
||||
if (isPunctuation(charAtOffset)) return;
|
||||
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
||||
for (const seg of segmenter.segment(text)) {
|
||||
if (seg.isWordLike && seg.index < offset && seg.index + seg.segment.length > offset) {
|
||||
range.setStart(node, seg.index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const snapEndToWordBoundary = () => {
|
||||
const node = range.endContainer;
|
||||
if (node.nodeType !== Node.TEXT_NODE) return;
|
||||
const text = node.textContent ?? '';
|
||||
const offset = range.endOffset;
|
||||
if (offset === 0 || offset >= text.length) return;
|
||||
|
||||
const charBeforeOffset = text[offset - 1] ?? '';
|
||||
if (isPunctuation(charBeforeOffset)) return;
|
||||
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
||||
for (const seg of segmenter.segment(text)) {
|
||||
if (seg.isWordLike && seg.index < offset && seg.index + seg.segment.length > offset) {
|
||||
range.setEnd(node, seg.index + seg.segment.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
snapStartToWordBoundary();
|
||||
snapEndToWordBoundary();
|
||||
};
|
||||
|
||||
export const getTextFromRange = (range: Range, rejectTags: string[] = []): string => {
|
||||
const clonedRange = range.cloneRange();
|
||||
const fragment = clonedRange.cloneContents();
|
||||
|
||||
@@ -259,10 +259,14 @@ const getLayoutStyles = (
|
||||
zoom: ${zoomLevel};
|
||||
}
|
||||
svg, img {
|
||||
height: auto;
|
||||
width: auto;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
svg:where(:not([width])), img:where(:not([width])) {
|
||||
width: auto;
|
||||
}
|
||||
svg:where(:not([height])), img:where(:not([height])) {
|
||||
height: auto;
|
||||
}
|
||||
figure > div:has(img) {
|
||||
height: auto !important;
|
||||
}
|
||||
@@ -841,7 +845,12 @@ export const applyTableStyle = (document: Document) => {
|
||||
const computedTableStyle = window.getComputedStyle(table);
|
||||
const computedWidth = computedTableStyle.width;
|
||||
if (computedWidth && computedWidth !== 'auto' && computedWidth !== '0px') {
|
||||
table.style.width = `calc(min(${computedWidth}, var(--available-width) * 1px))`;
|
||||
const widthValue = parseFloat(computedWidth);
|
||||
const widthUnit = computedWidth.replace(widthValue.toString(), '').trim();
|
||||
if (widthUnit !== '%') {
|
||||
// Workaround for hardcoded table layout, closes #3205
|
||||
table.style.width = `calc(min(${computedWidth}, var(--available-width)))`;
|
||||
}
|
||||
}
|
||||
if (totalTableWidth > 0) {
|
||||
const scale = `calc(min(1, var(--available-width) / ${totalTableWidth}))`;
|
||||
|
||||
+19
-20
@@ -59,6 +59,25 @@
|
||||
<launchable type="desktop-id">com.bilingify.readest.desktop</launchable>
|
||||
|
||||
<releases>
|
||||
<release version="0.9.100" date="2026-02-13">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Text-to-Speech: Automatically moves to the next chapter when the current chapter finishes</li>
|
||||
<li>Reading: Fixed the reading ruler on Android</li>
|
||||
<li>Reading: Improved RSVP layout and stability</li>
|
||||
<li>Reading: Fixed an issue where some EPUB books could not adjust font size</li>
|
||||
<li>Annotations: Added a magnifier (loupe) when adjusting text selection</li>
|
||||
<li>Annotations: Text selection now snaps to word boundaries for better accuracy</li>
|
||||
<li>Annotations: Custom highlight colors now apply correctly in the sidebar</li>
|
||||
<li>Dictionary: Added a back button for easier navigation</li>
|
||||
<li>Dictionary: Fixed an issue where Dictionary and Wikipedia could not open on Android</li>
|
||||
<li>Metadata: Added collapsible sections in Book Details</li>
|
||||
<li>Metadata: Added support for parsing series information from Calibre-exported EPUB files</li>
|
||||
<li>Eink: Improved component layout and colors in E-ink mode</li>
|
||||
<li>Layout: Sidebar toggle button position is now persistent</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.99" date="2026-02-07">
|
||||
<description>
|
||||
<ul>
|
||||
@@ -217,26 +236,6 @@
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.9.90" date="2025-10-28">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Sync: Faster library syncing and support for in-app one-time purchases of extra cloud storage</li>
|
||||
<li>TTS: You can now choose the target language for read-aloud on translated books</li>
|
||||
<li>Reader: New keyboard shortcuts make it easier to move between sections</li>
|
||||
<li>Reader: Smoother scrolling for a more comfortable reading experience</li>
|
||||
<li>Reader: Footnotes in definition lists are now fully supported</li>
|
||||
<li>Reader: Improved search accuracy and display of search results</li>
|
||||
<li>Reader: Clearer error messages when importing books</li>
|
||||
<li>Reader: Improved safety when loading book content</li>
|
||||
<li>Settings: You can now choose your own highlight colors</li>
|
||||
<li>Settings: Added option to adjust screen brightness automatically</li>
|
||||
<li>E-Ink: Better readability and clearer progress display with underlined links</li>
|
||||
<li>Android: You can now store app data on an external SD card</li>
|
||||
<li>iOS: Restores your last reading page if the app was closed in the background</li>
|
||||
<li>Localization: Added Persian (Farsi) language support</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
<metadata_license>FSFAP</metadata_license>
|
||||
|
||||
@@ -1 +1 @@
|
||||
41cc8ab4ec4e10a3b081215c9cdb830cd40ddb9eb86b3fc224698fc46b5ee2d8 ../../data/metainfo/appdata.xml
|
||||
89a2fdc3375b498db29e89907d4fa00e994be8ec6608689ab4d1ef3b94b83d59 ../../data/metainfo/appdata.xml
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 5d7b0bc982...72fda6ac40
Reference in New Issue
Block a user