Compare commits

...

29 Commits

Author SHA1 Message Date
Huang Xin f64739419b release: version 0.9.100 (#3279) 2026-02-12 18:08:30 +01:00
Huang Xin af8f036ca3 fix(annotator): apply custom highlight colors in sidebar, closes #3273 (#3278) 2026-02-12 17:43:11 +01:00
Huang Xin 24a87508c6 fix(layout): respect image dimension attribute, closes #3274 (#3277) 2026-02-12 16:41:57 +01:00
Huang Xin 548d50a882 fix(tts): navigate to follow the current TTS location for the next section, closes #3198 (#3276) 2026-02-12 16:13:41 +01:00
Huang Xin 1e81ab5205 doc: update sponsor link in README (#3275)
Updated the TestMu AI sponsor link in the README.
2026-02-12 14:10:54 +01:00
Huang Xin a47a480aa2 fix(layout): persistent location for the sidebar toggle, closes #3193 (#3269) 2026-02-11 16:29:26 +01:00
Huang Xin 411f9e236d feat(metadata): support parsing series info from calibre exported EPUBs, closes #3259 (#3267) 2026-02-11 15:39:48 +01:00
Huang Xin 950bbd0821 fix: expose srcdoc also for html sections, closes #3264 (#3266) 2026-02-11 13:15:56 +01:00
Huang Xin 2824e50b51 feat(annotator): snap text selection to word boundary, closes #3234 (#3265) 2026-02-11 12:53:23 +01:00
Huang Xin 129720c916 fix(eink): more legibility for the dictionary and wikipedia popups on Eink devices, closes #3258 (#3262) 2026-02-11 10:22:05 +01:00
Huang Xin fd3533dba1 feat(annotator): add a loupe when adjusting text selection range, closes #3002 (#3261) 2026-02-11 10:13:22 +01:00
Huang Xin 920032155b fix(tts): fix paused tts will still read to the end of the current paragraph, closes #3244 (#3256) 2026-02-11 03:06:12 +01:00
Huang Xin 226bf7033e fix(layout): fixed some layout issues for RSVP, closes #3199 (#3254) 2026-02-10 17:38:33 +01:00
Huang Xin 9444be7fcc feat(annotator): rounded highlight style for horizontal and vertical layout, closes #3208 (#3252) 2026-02-10 16:13:53 +01:00
Huang Xin c9a69a922b fix(layout): workaround for hardcoded table layout, closes #3205 (#3251) 2026-02-10 15:19:36 +01:00
Huang Xin 9a11b05833 fix: don't cache section content when updating subitems, closes #3242 and closes #3206 (#3250) 2026-02-10 14:29:57 +01:00
Huang Xin 6e603ee38f compat: compatibility with older webview on Android, closes #3245 (#3249) 2026-02-10 13:47:25 +01:00
Huang Xin 03fd6e2e6f feat(metadata): collapsible sections in book details, closes #3217 (#3243) 2026-02-09 17:56:34 +01:00
Huang Xin 7dd11c0fb0 fix: fixed docker build by including some dist files in docker image, closes #3233 (#3241) 2026-02-09 15:54:45 +01:00
Huang Xin 968597e52c doc: update toubleshooting and features list, closes #3224 (#3232) 2026-02-09 08:36:16 +01:00
Huang Xin a534050b19 fix(progress): show physical left pages instead of estimated ones, closes #3213 and closes #3200 (#3231) 2026-02-09 05:44:00 +01:00
Huang Xin 0be828fd66 feat(l10n): format sync date time with current locale, closes #3219 (#3230) 2026-02-09 03:09:52 +01:00
Huang Xin c6d4e2bdd6 fix(android): fixed some annotation tools not responsive to tap on Android, closes #3225 (#3229) 2026-02-09 02:26:57 +01:00
Mohammed Efaz bf72ab86cd fix: status button eink compatible (#3223) 2026-02-08 12:28:31 +01:00
Mohammed Efaz fb49ddf484 feat: back button dictionary (#3220)
* feat: add back button with history in dictionry/wikitionary

* fix: flicker back button
2026-02-08 12:27:05 +01:00
Huang Xin d8817a88b9 i18n: add support for Hebrew, closes #3216 (#3218) 2026-02-08 06:55:53 +01:00
Mohammed Efaz 475becafe3 fix: para mode nav buttons issue in eink (#3212) 2026-02-07 17:22:24 +01:00
Mohammed Efaz df165576e6 fix: reader ruler ubresponsive in android (#3210) 2026-02-07 23:16:28 +08:00
Huang Xin 051f2e5b13 fix(layout): show navigation buttons with higher z-index, closes #3201 (#3204) 2026-02-07 10:04:05 +01:00
84 changed files with 2750 additions and 1060 deletions
-2
View File
@@ -11,8 +11,6 @@ target
.gitignore
# Build outputs
dist
**/dist
.next
**/.next
out
+55 -22
View File
@@ -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 youve 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 systems 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 -1
View File
@@ -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": "元數據"
}
+18
View File
@@ -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]);
+2 -3
View File
@@ -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();
};
+5 -1
View File
@@ -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;
+10
View File
@@ -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);
+4
View File
@@ -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;
+7 -1
View File
@@ -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;
};
}
+11 -1
View File
@@ -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'];
+47
View File
@@ -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();
+12 -3
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
41cc8ab4ec4e10a3b081215c9cdb830cd40ddb9eb86b3fc224698fc46b5ee2d8 ../../data/metainfo/appdata.xml
89a2fdc3375b498db29e89907d4fa00e994be8ec6608689ab4d1ef3b94b83d59 ../../data/metainfo/appdata.xml