Compare commits

...

35 Commits

Author SHA1 Message Date
Huang Xin f4908c4536 release: version 0.9.31 (#839) 2025-04-08 15:54:34 +02:00
Huang Xin 37951c2ebf feat: add book metadata search in the bookshelf, closes #100 (#838) 2025-04-08 15:37:18 +02:00
Huang Xin 915f222e08 doc: add a troubleshooting section in README (#837) 2025-04-08 18:05:22 +08:00
Huang Xin 9446df1b9c feat: add an option to configure default CJK fonts in CJK environments, closes #780 (#836) 2025-04-08 11:27:59 +02:00
Huang Xin bfcf89b093 fix: toggle the menus on the whole screen when Click-To-Flip is disabled, closes #831 (#834) 2025-04-08 08:09:46 +02:00
Huang Xin b0cf087d78 fix: support opening content URIs by copying to cache when direct access fails, closes #829 (#833) 2025-04-08 07:45:33 +02:00
Huang Xin 5863586766 feat: vertical layout for highlighting tools, closes #787 (#830) 2025-04-07 17:24:14 +02:00
Huang Xin 99182a1f9e feat: add configurable timeout to stop TTS playback automatically, closes #747 (#826) 2025-04-07 12:40:14 +02:00
Huang Xin 2599f40dfb feat: add option to keep window on top, closes #820 (#825) 2025-04-07 08:23:46 +02:00
Huang Xin aa66ba54c5 fix: set proper default theme mode, closes #823 (#824) 2025-04-07 07:16:31 +02:00
Huang Xin 4f0ef01a17 fix: tts now works in background in iOS, closes #547 (#822)
To enable background playback in Android, go to Settings > Apps & Notifications > Readest > Battery > Battery Optimization, and disable battery optimization for Readest.
2025-04-06 18:42:58 +02:00
Huang Xin 267e1656db release: version 0.9.30 (#817) 2025-04-05 11:39:37 +02:00
Huang Xin dcecda2984 fix: cache NativeFile with LRU, closes #797 (#816) 2025-04-05 11:27:07 +02:00
Huang Xin ce88f0229a fix: rounded reader widget only when sidebar is invisible , closes #809 (#815) 2025-04-05 10:08:39 +02:00
Huang Xin 316c82ea52 fix: override link color only when overriding font, closes #812 (#814) 2025-04-05 09:34:29 +02:00
Huang Xin 25be60ce2a chore: bump tauri to 2.4.1 (#813) 2025-04-05 07:40:33 +02:00
Huang Xin 4703eec045 fix: set correct filename in openFile API, closes #803 (#810) 2025-04-05 12:41:26 +08:00
Huang Xin ab22a83332 fix: open default file chooser on Android, closes #798 and closes #799 (#807) 2025-04-04 05:01:12 +02:00
Huang Xin a3d6ebe44d feat: add fixed storage quota for self-hosted instance, closes #805 (#806) 2025-04-04 03:48:43 +02:00
Huang Xin f8804b5d3c fix: revert font family, size and color when overrideFont is set, closes #792 (#804) 2025-04-03 17:15:19 +02:00
Huang Xin ee3785ad51 fix: hack to decode incorrectly encoded url in href (#796) 2025-04-02 07:35:00 +02:00
Huang Xin 5ba40cd2d6 release: version 0.9.29 (#790) 2025-04-01 18:27:15 +02:00
Huang Xin 6131180a31 fix: initiate OAuth using Custom Tabs on Android, closes #361 (#788) 2025-04-01 17:04:17 +02:00
Huang Xin 7ccf3d0632 refactor: read local file with fs plugin for Tauri App, closes #553 and closes #489 (#785) 2025-04-01 14:26:26 +02:00
Huang Xin 39fb7f759e fix: handle preflight on tauri apps (#776) 2025-03-31 03:38:47 +02:00
Huang Xin d89834a53d chore: balance translator load between auth and guest users (#771) 2025-03-30 16:35:13 +02:00
Huang Xin 3c4dd2a143 fix: fix translation api with retry (#770) 2025-03-30 15:59:57 +02:00
Huang Xin 847efb7082 fix: head bar may hide login buttons in auth page (#769) 2025-03-30 11:32:57 +02:00
Huang Xin 01db8f90fa fix: store the filepath for transient import, closes #760 (#768) 2025-03-30 10:27:45 +02:00
Huang Xin e70c46ff02 fix: only replace quote punctuations for vertical layout, closes #761 (#767) 2025-03-30 09:49:22 +02:00
Huang Xin 7cb1998ebb fix: soft keyboard in custom css textarea, closes #762 and closes #763 (#766) 2025-03-30 09:33:27 +02:00
Huang Xin f5fd37f40d release: version 0.9.28 (hotfix) (#759) 2025-03-29 19:46:09 +01:00
Huang Xin 2daee62b80 fix: dismiss highlighting tools for annotation taking (#758) 2025-03-29 19:40:37 +01:00
Huang Xin ffd179bb06 fix: TXT file correctly read and imported on desktop apps, closes #756 (#757) 2025-03-29 19:11:41 +01:00
Huang Xin 4acc0d8e12 feat: replace punctuation marks in vertical reading mode (#754) 2025-03-29 12:42:23 +01:00
99 changed files with 2938 additions and 1357 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
run: cargo fmt --check
- name: Clippy Check
working-directory: apps/readest-app/src-tauri
run: cargo clippy -- -D warnings
run: cargo clippy -p Readest --no-deps -- -D warnings
build_web_app:
runs-on: ubuntu-latest
Generated
+801 -800
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1,3 +1,18 @@
[workspace]
members = ["apps/readest-app/src-tauri"]
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
]
resolver = "2"
[workspace.package]
authors = ["Bilingify LLC"]
homepage = "https://readest.com"
license = "AGPL-3.0"
repository = "https://github.com/readest/readest"
categories = []
edition = "2021"
rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
+34 -1
View File
@@ -26,7 +26,7 @@
<a href="#screenshots">Screenshots</a> •
<a href="#downloads">Downloads</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#contributors">Contributors</a> •
<a href="#troubleshooting">Troubleshooting</a> •
<a href="#license">License</a>
</p>
@@ -199,6 +199,39 @@ nix develop ./ops#android # enter a dev shell for the android app
Please check the [wiki][link-gh-wiki] of this project for more information on development.
## Troubleshooting
### 1. Readest Wont Launch on Windows (Missing Edge WebView2 Runtime)
**Symptom**
- When you double-click readest.exe, nothing happens. No window appears, and Task Manager does not show the process.
- This can affect both the standard installer and the portable version.
**Cause**
- Microsoft Edge WebView2 Runtime is either missing, outdated, or improperly installed on your system. Readest depends on WebView2 to render the interface on Windows.
**How to Fix**
1. Check if WebView2 is installed
- Open “Add or Remove Programs” (a.k.a. Apps & features) on Windows. Look for “Microsoft Edge WebView2 Runtime.”
2. Install or Update WebView2
- Download the WebView2 Runtime directly from Microsoft: [link](https://developer.microsoft.com/en-us/microsoft-edge/webview2?form=MA13LH).
- If you prefer an offline installer, download the offline package and run it as an Administrator.
3. Re-run Readest
- After installing/updating WebView2, launch readest.exe again.
- If you still encounter problems, reboot your PC and try again.
**Additional Tips**
- If reinstalling once doesnt work, uninstall Edge WebView2 completely, then reinstall it with Administrator privileges.
- Verify your Windows installation has the latest updates from Microsoft.
**Still Stuck?**
- 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.
## 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.
+2
View File
@@ -4,6 +4,8 @@ NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.27",
"version": "0.9.31",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -42,19 +42,19 @@
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.3.0",
"@tauri-apps/api": "2.4.1",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-haptics": "^2.2.3",
"@tauri-apps/plugin-http": "^2.3.0",
"@tauri-apps/plugin-log": "^2.2.3",
"@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-haptics": "^2.2.4",
"@tauri-apps/plugin-http": "^2.4.3",
"@tauri-apps/plugin-log": "^2.3.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "~2.2.0",
"@tauri-apps/plugin-updater": "^2.5.1",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "~2.2.1",
"@tauri-apps/plugin-updater": "^2.7.0",
"@zip.js/zip.js": "^2.7.53",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
@@ -79,7 +79,7 @@
},
"devDependencies": {
"@opennextjs/cloudflare": "^0.5.12",
"@tauri-apps/cli": "2.3.1",
"@tauri-apps/cli": "2.4.1",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
@@ -152,7 +152,6 @@
"Text Indent": "مسافة بادئة للنص",
"Paragraph Margin": "هامش الفقرة",
"Override Book Layout": "تجاوز تخطيط الكتاب",
"Add to Group": "إضافة إلى مجموعة",
"Untitled Group": "مجموعة بدون عنوان",
"Group Books": "تجميع الكتب",
"Remove From Group": "إزالة من المجموعة",
@@ -261,5 +260,18 @@
"Export Annotations": "تصدير التعليقات",
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "تبديل منطقة التقليب بالنقر"
"Swap Click-to-Flip Area": "تبديل منطقة التقليب بالنقر",
"No chapters detected.": "لم يتم التعرف على فصول.",
"Failed to parse the EPUB file.": "فشل في تحليل ملف EPUB.",
"This book format is not supported.": "هذا تنسيق الكتاب غير مدعوم.",
"Unable to fetch the translation. Please log in first and try again.": "تعذر جلب الترجمة. يرجى تسجيل الدخول أولاً ثم المحاولة مرة أخرى.",
"Group": "تجميع",
"Always on Top": "دائمًا في المقدمة",
"No Timeout": "لا مهلة",
"{{value}} minute": "{{value}} دقيقة",
"{{value}} minutes": "{{value}} دقائق",
"{{value}} hour": "{{value}} ساعة",
"{{value}} hours": "{{value}} ساعات",
"CJK Font": "خط CJK",
"Clear Search": "مسح البحث"
}
@@ -152,7 +152,6 @@
"Text Indent": "Texteinzug",
"Paragraph Margin": "Absatzrand",
"Override Book Layout": "Buch-Layout überschreiben",
"Add to Group": "Zur Gruppe hinzufügen",
"Untitled Group": "Unbenannte Gruppe",
"Group Books": "Bücher gruppieren",
"Remove From Group": "Aus Gruppe entfernen",
@@ -261,5 +260,18 @@
"Export Annotations": "Anmerkungen exportieren",
"Auto Import on File Open": "Automatischer Import beim Öffnen einer Datei",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Klick-zu-Blättern-Bereich tauschen"
"Swap Click-to-Flip Area": "Klick-zu-Blättern-Bereich tauschen",
"No chapters detected.": "Keine Kapitel erkannt.",
"Failed to parse the EPUB file.": "Fehler beim Parsen der EPUB-Datei.",
"This book format is not supported.": "Dieses Buchformat wird nicht unterstützt.",
"Unable to fetch the translation. Please log in first and try again.": "Übersetzung konnte nicht abgerufen werden. Bitte zuerst anmelden und erneut versuchen.",
"Group": "Gruppieren",
"Always on Top": "Immer im Vordergrund",
"No Timeout": "Kein Timeout",
"{{value}} minute": "{{value}} Minute",
"{{value}} minutes": "{{value}} Minuten",
"{{value}} hour": "{{value}} Stunde",
"{{value}} hours": "{{value}} Stunden",
"CJK Font": "CJK-Schriftart",
"Clear Search": "Suche löschen"
}
@@ -152,7 +152,6 @@
"Text Indent": "Εσοχή κειμένου",
"Paragraph Margin": "Περιθώριο παραγράφου",
"Override Book Layout": "Παράκαμψη διάταξης βιβλίου",
"Add to Group": "Προσθήκη στην ομάδα",
"Untitled Group": "Ομάδα χωρίς τίτλο",
"Group Books": "Ομαδοποίηση βιβλίων",
"Remove From Group": "Αφαίρεση από την ομάδα",
@@ -261,5 +260,18 @@
"Export Annotations": "Εξαγωγή σχολιασμών",
"Auto Import on File Open": "Αυτόματη εισαγωγή κατά το άνοιγμα αρχείου",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας"
"Swap Click-to-Flip Area": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας",
"No chapters detected.": "Δεν εντοπίστηκαν κεφάλαια.",
"Failed to parse the EPUB file.": "Αποτυχία ανάλυσης του αρχείου EPUB.",
"This book format is not supported.": "Αυτή η μορφή βιβλίου δεν υποστηρίζεται.",
"Unable to fetch the translation. Please log in first and try again.": "Αδυναμία λήψης της μετάφρασης. Παρακαλώ συνδεθείτε πρώτα και δοκιμάστε ξανά.",
"Group": "Ομάδα",
"Always on Top": "Πάντα στην κορυφή",
"No Timeout": "Χωρίς χρονικό όριο",
"{{value}} minute": "{{value}} λεπτό",
"{{value}} minutes": "{{value}} λεπτά",
"{{value}} hour": "{{value}} ώρα",
"{{value}} hours": "{{value}} ώρες",
"CJK Font": "Γραμματοσειρά CJK",
"Clear Search": "Καθαρισμός αναζήτησης"
}
@@ -152,7 +152,6 @@
"Text Indent": "Sangría de texto",
"Paragraph Margin": "Margen de párrafo",
"Override Book Layout": "Sobrescribir diseño del libro",
"Add to Group": "Añadir a grupo",
"Untitled Group": "Grupo sin título",
"Group Books": "Agrupar libros",
"Remove From Group": "Eliminar del grupo",
@@ -261,5 +260,18 @@
"Export Annotations": "Exportar anotaciones",
"Auto Import on File Open": "Importación automática al abrir archivo",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Intercambiar área de clic para voltear"
"Swap Click-to-Flip Area": "Intercambiar área de clic para voltear",
"No chapters detected.": "No se detectaron capítulos.",
"Failed to parse the EPUB file.": "Error al analizar el archivo EPUB.",
"This book format is not supported.": "Este formato de libro no es compatible.",
"Unable to fetch the translation. Please log in first and try again.": "No se puede obtener la traducción. Por favor, inicia sesión primero e inténtalo de nuevo.",
"Group": "Grupo",
"Always on Top": "Siempre en la parte superior",
"No Timeout": "Sin tiempo de espera",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minutos",
"{{value}} hour": "{{value}} hora",
"{{value}} hours": "{{value}} horas",
"CJK Font": "Fuente CJK",
"Clear Search": "Limpiar búsqueda"
}
@@ -152,7 +152,6 @@
"Text Indent": "Retrait du texte",
"Paragraph Margin": "Marge de paragraphe",
"Override Book Layout": "Remplacer la mise en page du livre",
"Add to Group": "Ajouter au groupe",
"Untitled Group": "Groupe sans titre",
"Group Books": "Regrouper les livres",
"Remove From Group": "Retirer du groupe",
@@ -261,5 +260,18 @@
"Export Annotations": "Exporter les annotations",
"Auto Import on File Open": "Importation automatique à l'ouverture du fichier",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Échanger la zone de clic pour tourner"
"Swap Click-to-Flip Area": "Échanger la zone de clic pour tourner",
"No chapters detected.": "Aucun chapitre détecté.",
"Failed to parse the EPUB file.": "Échec de l'analyse du fichier EPUB.",
"This book format is not supported.": "Ce format de livre n'est pas pris en charge.",
"Unable to fetch the translation. Please log in first and try again.": "Impossible de récupérer la traduction. Veuillez d'abord vous connecter et réessayer.",
"Group": "Groupe",
"Always on Top": "Toujours au-dessus",
"No Timeout": "Aucun délai d'attente",
"{{value}} minute": "{{value}} minute",
"{{value}} minutes": "{{value}} minutes",
"{{value}} hour": "{{value}} heure",
"{{value}} hours": "{{value}} heures",
"CJK Font": "Police CJK",
"Clear Search": "Effacer la recherche"
}
@@ -152,7 +152,6 @@
"Text Indent": "टेक्स्ट इंडेंट",
"Paragraph Margin": "पैराग्राफ मार्जिन",
"Override Book Layout": "पुस्तक लेआउट ओवरराइड करें",
"Add to Group": "समूह में जोड़ें",
"Untitled Group": "अभिनामित समूह",
"Group Books": "समूह पुस्तकें",
"Remove From Group": "समूह से हटाएं",
@@ -261,5 +260,18 @@
"Export Annotations": "टिप्पणियाँ निर्यात करें",
"Auto Import on File Open": "फ़ाइल खोलने पर स्वचालित आयात",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें"
"Swap Click-to-Flip Area": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें",
"No chapters detected.": "कोई अध्याय नहीं मिला।",
"Failed to parse the EPUB file.": "EPUB फ़ाइल को पार्स करने में विफल।",
"This book format is not supported.": "यह पुस्तक प्रारूप समर्थित नहीं है।",
"Unable to fetch the translation. Please log in first and try again.": "अनुवाद प्राप्त करने में असमर्थ। कृपया पहले लॉग इन करें और पुनः प्रयास करें।",
"Group": "समूह",
"Always on Top": "सर्वोच्च पर हमेशा",
"No Timeout": "कोई समय समाप्त नहीं",
"{{value}} minute": "{{value}} मिनट",
"{{value}} minutes": "{{value}} मिनट",
"{{value}} hour": "{{value}} घंटा",
"{{value}} hours": "{{value}} घंटे",
"CJK Font": "CJK फ़ॉन्ट",
"Clear Search": "खोज साफ करें"
}
@@ -152,7 +152,6 @@
"Text Indent": "Indentasi Teks",
"Paragraph Margin": "Margin Paragraf",
"Override Book Layout": "Ganti Tata Letak Buku",
"Add to Group": "Tambahkan ke Grup",
"Untitled Group": "Grup Tanpa Judul",
"Group Books": "Grupkan Buku",
"Remove From Group": "Hapus dari Grup",
@@ -261,5 +260,18 @@
"Export Annotations": "Ekspor Anotasi",
"Auto Import on File Open": "Impor Otomatis saat File Dibuka",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Tukar Area Klik untuk Membalik"
"Swap Click-to-Flip Area": "Tukar Area Klik untuk Membalik",
"No chapters detected.": "Tidak ada bab yang terdeteksi.",
"Failed to parse the EPUB file.": "Gagal mengurai file EPUB.",
"This book format is not supported.": "Format buku ini tidak didukung.",
"Unable to fetch the translation. Please log in first and try again.": "Tidak dapat mengambil terjemahan. Silakan masuk terlebih dahulu dan coba lagi.",
"Group": "Grup",
"Always on Top": "Selalu di Atas",
"No Timeout": "Tidak Ada Waktu Habis",
"{{value}} minute": "{{value}} menit",
"{{value}} minutes": "{{value}} menit",
"{{value}} hour": "{{value}} jam",
"{{value}} hours": "{{value}} jam",
"CJK Font": "Font CJK",
"Clear Search": "Hapus Pencarian"
}
@@ -152,7 +152,6 @@
"Text Indent": "Rientro testo",
"Paragraph Margin": "Margine paragrafo",
"Override Book Layout": "Sovrascrivi layout libro",
"Add to Group": "Aggiungi al gruppo",
"Untitled Group": "Gruppo senza titolo",
"Group Books": "Raggruppa libri",
"Remove From Group": "Rimuovi dal",
@@ -261,5 +260,18 @@
"Export Annotations": "Esporta annotazioni",
"Auto Import on File Open": "Importazione automatica all'apertura del file",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Scambia area click per voltare pagina"
"Swap Click-to-Flip Area": "Scambia area click per voltare pagina",
"No chapters detected.": "Nessun capitolo rilevato.",
"Failed to parse the EPUB file.": "Impossibile analizzare il file EPUB.",
"This book format is not supported.": "Questo formato di libro non è supportato.",
"Unable to fetch the translation. Please log in first and try again.": "Impossibile recuperare la traduzione. Accedi prima e riprova.",
"Group": "Gruppo",
"Always on Top": "Sempre in primo piano",
"No Timeout": "Nessun timeout",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minuti",
"{{value}} hour": "{{value}} ora",
"{{value}} hours": "{{value}} ore",
"CJK Font": "Font CJK",
"Clear Search": "Cancella ricerca"
}
@@ -152,7 +152,6 @@
"Text Indent": "テキストインデント",
"Paragraph Margin": "段落マージン",
"Override Book Layout": "書籍のレイアウトを上書き",
"Add to Group": "グループに追加",
"Untitled Group": "無題のグループ",
"Group Books": "書籍をグループ化",
"Remove From Group": "グループから削除",
@@ -261,5 +260,18 @@
"Export Annotations": "注釈をエクスポート",
"Auto Import on File Open": "ファイルを開くと自動インポート",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "クリックめくりエリアを入れ替え"
"Swap Click-to-Flip Area": "クリックめくりエリアを入れ替え",
"No chapters detected.": "章が検出されません。",
"Failed to parse the EPUB file.": "EPUBファイルの解析に失敗しました。",
"This book format is not supported.": "この書籍形式はサポートされていません。",
"Unable to fetch the translation. Please log in first and try again.": "翻訳を取得できません。まずログインして再試行してください。",
"Group": "グループ",
"Always on Top": "常に最前面",
"No Timeout": "タイムアウトなし",
"{{value}} minute": "{{value}}分",
"{{value}} minutes": "{{value}}分",
"{{value}} hour": "{{value}}時間",
"{{value}} hours": "{{value}}時間",
"CJK Font": "CJKフォント",
"Clear Search": "検索をクリア"
}
@@ -152,7 +152,6 @@
"Text Indent": "들여쓰기",
"Paragraph Margin": "단락 여백",
"Override Book Layout": "책 레이아웃 덮어쓰기",
"Add to Group": "그룹에 추가",
"Untitled Group": "제목 없는 그룹",
"Group Books": "책 그룹화",
"Remove From Group": "그룹에서 제거",
@@ -261,5 +260,18 @@
"Export Annotations": "주석 내보내기",
"Auto Import on File Open": "파일 열 때 자동 가져오기",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "클릭-뒤집기 영역 교환"
"Swap Click-to-Flip Area": "클릭-뒤집기 영역 교환",
"No chapters detected.": "장 발견되지 않음.",
"Failed to parse the EPUB file.": "EPUB 파일 구문 분석 실패.",
"This book format is not supported.": "이 책 형식은 지원되지 않습니다.",
"Unable to fetch the translation. Please log in first and try again.": "번역을 가져올 수 없습니다. 먼저 로그인하고 다시 시도하세요.",
"Group": "그룹",
"Always on Top": "항상 위에",
"No Timeout": "타임아웃 없음",
"{{value}} minute": "{{value}} 분",
"{{value}} minutes": "{{value}} 분",
"{{value}} hour": "{{value}} 시간",
"{{value}} hours": "{{value}} 시간",
"CJK Font": "CJK 글꼴",
"Clear Search": "검색 지우기"
}
@@ -152,7 +152,6 @@
"Text Indent": "Wcięcie tekstu",
"Paragraph Margin": "Margines akapitu",
"Override Book Layout": "Nadpisz układ książki",
"Add to Group": "Dodaj do grupy",
"Untitled Group": "Grupa bez tytułu",
"Group Books": "Grupuj książki",
"Remove From Group": "Usuń z grupy",
@@ -261,5 +260,18 @@
"Export Annotations": "Eksportuj adnotacje",
"Auto Import on File Open": "Automatyczne importowanie przy otwieraniu pliku",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Zamień obszar kliknięcia na przewracanie"
"Swap Click-to-Flip Area": "Zamień obszar kliknięcia na przewracanie",
"No chapters detected.": "Nie wykryto rozdziałów.",
"Failed to parse the EPUB file.": "Nie udało się przeanalizować pliku EPUB.",
"This book format is not supported.": "Ten format książki nie jest obsługiwany.",
"Unable to fetch the translation. Please log in first and try again.": "Nie można pobrać tłumaczenia. Proszę najpierw się zalogować i spróbować ponownie.",
"Group": "Grupa",
"Always on Top": "Zawsze na wierzchu",
"No Timeout": "Brak limitu czasu",
"{{value}} minute": "{{value}} minuta",
"{{value}} minutes": "{{value}} minut",
"{{value}} hour": "{{value}} godzina",
"{{value}} hours": "{{value}} godziny",
"CJK Font": "Czcionka CJK",
"Clear Search": "Wyczyść wyszukiwanie"
}
@@ -152,7 +152,6 @@
"Text Indent": "Recuo de Texto",
"Paragraph Margin": "Margem de Parágrafo",
"Override Book Layout": "Sobrescrever Layout do Livro",
"Add to Group": "Adicionar ao Grupo",
"Untitled Group": "Grupo Sem Título",
"Group Books": "Agrupar Livros",
"Remove From Group": "Remover do Grupo",
@@ -261,5 +260,18 @@
"Export Annotations": "Exportar Anotações",
"Auto Import on File Open": "Importação Automática ao Abrir Arquivo",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Trocar Área de Clique para Virar"
"Swap Click-to-Flip Area": "Trocar Área de Clique para Virar",
"No chapters detected.": "Nenhum capítulo detectado.",
"Failed to parse the EPUB file.": "Falha ao analisar o arquivo EPUB.",
"This book format is not supported.": "Este formato de livro não é suportado.",
"Unable to fetch the translation. Please log in first and try again.": "Não foi possível buscar a tradução. Faça login primeiro e tente novamente.",
"Group": "Grupo",
"Always on Top": "Sempre no Topo",
"No Timeout": "Sem Tempo Limite",
"{{value}} minute": "{{value}} minuto",
"{{value}} minutes": "{{value}} minutos",
"{{value}} hour": "{{value}} hora",
"{{value}} hours": "{{value}} horas",
"CJK Font": "Fonte CJK",
"Clear Search": "Limpar Pesquisa"
}
@@ -152,7 +152,6 @@
"Text Indent": "Отступ текста",
"Paragraph Margin": "Отступ абзаца",
"Override Book Layout": "Переопределить макет книги",
"Add to Group": "Добавить в группу",
"Untitled Group": "Группа без названия",
"Group Books": "Группировать книги",
"Remove From Group": "Удалить из группы",
@@ -261,5 +260,18 @@
"Export Annotations": "Экспортировать аннотации",
"Auto Import on File Open": "Автоматический импорт при открытии файла",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Переключить область клика для переворота"
"Swap Click-to-Flip Area": "Переключить область клика для переворота",
"No chapters detected.": "Не обнаружено глав.",
"Failed to parse the EPUB file.": "Не удалось разобрать файл EPUB.",
"This book format is not supported.": "Этот формат книги не поддерживается.",
"Unable to fetch the translation. Please log in first and try again.": "Не удалось получить перевод. Пожалуйста, войдите в систему и попробуйте снова.",
"Group": "Группа",
"Always on Top": "Всегда сверху",
"No Timeout": "Нет таймаута",
"{{value}} minute": "{{value}} минута",
"{{value}} minutes": "{{value}} минуты",
"{{value}} hour": "{{value}} час",
"{{value}} hours": "{{value}} часа",
"CJK Font": "CJK Шрифт",
"Clear Search": "Очистить поиск"
}
@@ -152,7 +152,6 @@
"Text Indent": "Metin Girintisi",
"Paragraph Margin": "Paragraf Kenarı",
"Override Book Layout": "Kitap Düzenini Geçersiz Kıl",
"Add to Group": "Gruba Ekle",
"Untitled Group": "Başlıksız Grup",
"Group Books": "Kitapları Grupla",
"Remove From Group": "Grubu Kaldır",
@@ -261,5 +260,18 @@
"Export Annotations": "Notları Dışa Aktar",
"Auto Import on File Open": "Dosya Açıldığında Otomatik İçe Aktar",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Tıkla-Çevir Alanını Değiştir"
"Swap Click-to-Flip Area": "Tıkla-Çevir Alanını Değiştir",
"No chapters detected.": "Bölüm bulunamadı.",
"Failed to parse the EPUB file.": "EPUB dosyası ayrıştırılamadı.",
"This book format is not supported.": "Bu kitap formatı desteklenmiyor.",
"Unable to fetch the translation. Please log in first and try again.": "Çeviri alınamıyor. Lütfen önce giriş yapın ve tekrar deneyin.",
"Group": "Grup",
"Always on Top": "Her Zaman Üstte",
"No Timeout": "Zaman Aşımı Yok",
"{{value}} minute": "{{value}} dakika",
"{{value}} minutes": "{{value}} dakika",
"{{value}} hour": "{{value}} saat",
"{{value}} hours": "{{value}} saat",
"CJK Font": "CJK Yazı Tipi",
"Clear Search": "Aramayı Temizle"
}
@@ -152,7 +152,6 @@
"Text Indent": "Відступ тексту",
"Paragraph Margin": "Відступ абзацу",
"Override Book Layout": "Перевизначити макет книги",
"Add to Group": "Додати до групи",
"Untitled Group": "Група без назви",
"Group Books": "Групувати книги",
"Remove From Group": "Видалити з групи",
@@ -261,5 +260,18 @@
"Export Annotations": "Експортувати анотації",
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Поміняти місцями область кліку для перевороту"
"Swap Click-to-Flip Area": "Поміняти місцями область кліку для перевороту",
"No chapters detected.": "Не виявлено жодного розділу.",
"Failed to parse the EPUB file.": "Не вдалося розібрати файл EPUB.",
"This book format is not supported.": "Цей формат книги не підтримується.",
"Unable to fetch the translation. Please log in first and try again.": "Не вдалося отримати переклад. Будь ласка, спочатку увійдіть у систему та спробуйте ще раз.",
"Group": "Група",
"Always on Top": "Завжди зверху",
"No Timeout": "Без тайм-ауту",
"{{value}} minute": "{{value}} хвилина",
"{{value}} minutes": "{{value}} хвилин",
"{{value}} hour": "{{value}} година",
"{{value}} hours": "{{value}} години",
"CJK Font": "CJK шрифт",
"Clear Search": "Очистити пошук"
}
@@ -152,7 +152,6 @@
"Text Indent": "Thụt lề",
"Paragraph Margin": "Lề đoạn",
"Override Book Layout": "Ghi đè bố cục sách",
"Add to Group": "Thêm vào nhóm",
"Untitled Group": "Nhóm không có tiêu đề",
"Group Books": "Nhóm sách",
"Remove From Group": "Xóa khỏi nhóm",
@@ -261,5 +260,18 @@
"Export Annotations": "Xuất chú thích",
"Auto Import on File Open": "Tự động nhập khi mở tệp",
"LXGW WenKai GB Screen": "LXGW WenKai",
"Swap Click-to-Flip Area": "Hoán đổi khu vực nhấp để lật trang"
"Swap Click-to-Flip Area": "Hoán đổi khu vực nhấp để lật trang",
"No chapters detected.": "Không phát hiện thấy chương nào.",
"Failed to parse the EPUB file.": "Không thể phân tích tệp EPUB.",
"This book format is not supported.": "Định dạng sách này không được hỗ trợ.",
"Unable to fetch the translation. Please log in first and try again.": "Không thể tải dịch vụ. Vui lòng đăng nhập trước và thử lại.",
"Group": "Nhóm",
"Always on Top": "Luôn ở trên cùng",
"No Timeout": "Không có thời gian chờ",
"{{value}} minute": "{{value}} phút",
"{{value}} minutes": "{{value}} phút",
"{{value}} hour": "{{value}} giờ",
"{{value}} hours": "{{value}} giờ",
"CJK Font": "Phông chữ CJK",
"Clear Search": "Xóa tìm kiếm"
}
@@ -152,7 +152,6 @@
"Text Indent": "首行缩进",
"Paragraph Margin": "段间距",
"Override Book Layout": "覆盖版面布局",
"Add to Group": "添加到分组",
"Untitled Group": "无标题分组",
"Group Books": "分组书籍",
"Remove From Group": "从分组中移除",
@@ -231,7 +230,7 @@
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列宽",
"Continuous Scroll": "连续滚动",
"Fullscreen": "全屏",
"Fullscreen": "全屏显示",
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
"Drop to Import Books": "拖放导入书籍",
"Custom": "自定义",
@@ -261,5 +260,18 @@
"Export Annotations": "导出笔记",
"Auto Import on File Open": "打开文件时自动导入",
"LXGW WenKai GB Screen": "霞鹜文楷",
"Swap Click-to-Flip Area": "交换点击翻页区域"
"Swap Click-to-Flip Area": "交换点击翻页区域",
"No chapters detected.": "未检测到章节。",
"Failed to parse the EPUB file.": "无法解析 EPUB 文件。",
"This book format is not supported.": "不支持此书籍格式。",
"Unable to fetch the translation. Please log in first and try again.": "无法获取翻译。请登录后重试。",
"Group": "分组",
"Always on Top": "窗口置顶",
"No Timeout": "关闭定时",
"{{value}} minute": "{{value}}分钟",
"{{value}} minutes": "{{value}}分钟",
"{{value}} hour": "{{value}}小时",
"{{value}} hours": "{{value}}小时",
"CJK Font": "中文字体",
"Clear Search": "清除搜索"
}
@@ -152,7 +152,6 @@
"Text Indent": "首行縮進",
"Paragraph Margin": "段間距",
"Override Book Layout": "覆蓋版面佈局",
"Add to Group": "添加到組",
"Untitled Group": "無標題組",
"Group Books": "分組書籍",
"Remove From Group": "從組中移除",
@@ -261,5 +260,18 @@
"Export Annotations": "匯出筆記",
"Auto Import on File Open": "打開文件時自動導入",
"LXGW WenKai GB Screen": "霞鶩文楷",
"Swap Click-to-Flip Area": "交換點擊翻頁區域"
"Swap Click-to-Flip Area": "交換點擊翻頁區域",
"No chapters detected.": "未檢測到章節。",
"Failed to parse the EPUB file.": "無法解析 EPUB 文件。",
"This book format is not supported.": "不支援此書籍格式。",
"Unable to fetch the translation. Please log in first and try again.": "無法獲取翻譯。請先登入然後重試。",
"Group": "分組",
"Always on Top": "視窗置頂",
"No Timeout": "關閉定時",
"{{value}} minute": "{{value}}分鐘",
"{{value}} minutes": "{{value}}分鐘",
"{{value}} hour": "{{value}}小時",
"{{value}} hours": "{{value}}小時",
"CJK Font": "中文字體",
"Clear Search": "清除搜索"
}
+36
View File
@@ -1,5 +1,41 @@
{
"releases": {
"0.9.31": {
"date": "2025-04-08",
"notes": [
"iOS: Text-to-Speech (TTS) now works in the background",
"Android: Import files from native file manager",
"Desktop: Add option to keep window on top",
"Vertical layout for highlighting tools",
"Added options to auto-stop TTS after timeout",
"Added book search functionality in bookshelf",
"Improved font support for CJK users"
]
},
"0.9.30": {
"date": "2025-04-05",
"notes": [
"Added fixed storage quata when running the app in self-hosted mode",
"Link colors will only change when you override fonts",
"Added cache for native files for better performance"
]
},
"0.9.29": {
"date": "2025-04-02",
"notes": [
"Improved Login on Android without needing to rely on external Chrome browser",
"Local large book files now load more efficiently on Android",
"Balanced the load of online service between different types of users to keep things running smoothly for everyone"
]
},
"0.9.28": {
"date": "2025-03-30",
"notes": [
"Improved vertical reading mode with better support for Chinese punctuations",
"Fixed TXT file importing issue on desktop apps — TXT books now load reliably and display as expected",
"Improved annotation experience — highlighting tools are now properly dismissed when adding notes to avoid UI conflicts"
]
},
"0.9.27": {
"date": "2025-03-29",
"notes": [
+17 -20
View File
@@ -1,6 +1,6 @@
[package]
name = "Readest"
version = "0.2.1"
version = "0.2.2"
description = "Your online library"
authors = ["Bilingify LLC"]
license = ""
@@ -15,7 +15,7 @@ name = "readestlib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2.0.3", features = [] }
tauri-build = { version = "2.1.1", features = [] }
[dependencies]
serde_json = "1.0"
@@ -31,24 +31,21 @@ reqwest = { version = "0.12", default-features = false, features = [
"stream",
] }
# FIXME: remove the devtools feature in production
tauri = { version = "2.1.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.0.3"
tauri-plugin-log = "2.0.2"
tauri-plugin-fs = "2.0.3"
tauri-plugin-dialog = "2.0.3"
tauri-plugin-os = "2.0.1"
tauri-plugin-http = "2.0.3"
tauri-plugin-devtools = "2.0.0"
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri = { version = "2.4.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.1.1"
tauri-plugin-log = "2.3.1"
tauri-plugin-fs = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-os = "2.2.1"
tauri-plugin-http = "2.4.3"
tauri-plugin-shell = "2.2.1"
tauri-plugin-process = "2.2.1"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2.2.6"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.3"
tauri-plugin-haptics = "2.2.4"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.25"
@@ -57,7 +54,7 @@ rand = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.0"
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-updater = "2.7.0"
tauri-plugin-window-state = "2.2.2"
font-enumeration = "0.9.0"
@@ -63,6 +63,8 @@
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"core:window:allow-set-always-on-top",
"log:default",
"shell:default",
"process:default",
"process:allow-exit",
@@ -19,6 +19,12 @@
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter android:label="oauth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="readest" android:host="auth-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -2,10 +2,14 @@ package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.net.Uri
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.activity.enableEdgeToEdge
import app.tauri.plugin.JSObject
import com.readest.native_bridge.NativeBridgePlugin
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -15,6 +19,20 @@ class MainActivity : TauriActivity() {
hideSystemUI()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val uri = intent.data ?: return
if (uri.scheme == "readest" && uri.host == "auth-callback") {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
NativeBridgePlugin.pendingInvoke?.resolve(result)
NativeBridgePlugin.pendingInvoke = null
}
}
private fun hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.decorView.windowInsetsController?.let { controller ->
@@ -33,9 +33,9 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.browser:browser:1.8.0")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
@@ -3,7 +3,4 @@ package com.readest.native_bridge
import android.util.Log
class NativeBridge {
fun auth_with_safari(value: String): String {
return ""
}
}
@@ -1,28 +1,77 @@
package com.readest.native_bridge
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import java.io.*
@InvokeArg
class SafariAuthRequestArgs {
class AuthRequestArgs {
var authUrl: String? = null
}
@InvokeArg
class CopyURIRequestArgs {
var uri: String? = null
var dst: String? = null
}
@TauriPlugin
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
private var redirectScheme = "readest"
private var redirectHost = "auth-callback"
companion object {
var pendingInvoke: Invoke? = null
}
@Command
fun auth_with_safari(invoke: Invoke) {
val args = invoke.parseArgs(SafariAuthRequestArgs::class.java)
fun auth_with_custom_tab(invoke: Invoke) {
val args = invoke.parseArgs(AuthRequestArgs::class.java)
val uri = Uri.parse(args.authUrl)
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY
Log.d("NativeBridgePlugin", "Launching OAuth URL: ${args.authUrl}")
customTabsIntent.launchUrl(activity, uri)
pendingInvoke = invoke
}
@Command
fun copy_uri_to_path(invoke: Invoke) {
val args = invoke.parseArgs(CopyURIRequestArgs::class.java)
val ret = JSObject()
ret.put("redirectUrl", implementation.auth_with_safari(args.authUrl ?: ""))
try {
val uri = Uri.parse(args.uri ?: "")
val dst = File(args.dst ?: "")
val inputStream = activity.contentResolver.openInputStream(uri)
if (inputStream != null) {
dst.outputStream().use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
ret.put("success", true)
} else {
ret.put("success", false)
ret.put("error", "Failed to open input stream from URI")
}
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
}
@@ -1,8 +1,13 @@
const COMMANDS: &[&str] = &["auth_with_safari"];
const COMMANDS: &[&str] = &[
"auth_with_safari",
"auth_with_custom_tab",
"copy_uri_to_path",
"use_background_audio",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -1,4 +1,6 @@
import AuthenticationServices
import AVFoundation
import MediaPlayer
import SwiftRs
import Tauri
import UIKit
@@ -8,9 +10,33 @@ class SafariAuthRequestArgs: Decodable {
let authUrl: String
}
class UseBackgroundAudioRequestArgs: Decodable {
let enabled: Bool
}
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
@objc public func use_background_audio(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(UseBackgroundAudioRequestArgs.self)
let enabled = args.enabled
let session = AVAudioSession.sharedInstance()
if enabled {
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try session.setActive(true)
print("AVAudioSession activated")
} else {
try session.setActive(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("AVAudioSession deactivated")
}
invoke.resolve()
} catch {
print("Failed to set up audio session:", error)
}
}
@objc public func auth_with_safari(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(SafariAuthRequestArgs.self)
let authUrl = URL(string: args.authUrl)!
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-auth-with-custom-tab"
description = "Enables the auth_with_custom_tab command without any pre-configured scope."
commands.allow = ["auth_with_custom_tab"]
[[permission]]
identifier = "deny-auth-with-custom-tab"
description = "Denies the auth_with_custom_tab command without any pre-configured scope."
commands.deny = ["auth_with_custom_tab"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-copy-uri-to-path"
description = "Enables the copy_uri_to_path command without any pre-configured scope."
commands.allow = ["copy_uri_to_path"]
[[permission]]
identifier = "deny-copy-uri-to-path"
description = "Denies the copy_uri_to_path command without any pre-configured scope."
commands.deny = ["copy_uri_to_path"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-use-background-audio"
description = "Enables the use_background_audio command without any pre-configured scope."
commands.allow = ["use_background_audio"]
[[permission]]
identifier = "deny-use-background-audio"
description = "Denies the use_background_audio command without any pre-configured scope."
commands.deny = ["use_background_audio"]
@@ -2,7 +2,12 @@
Default permissions for the plugin
#### This default permission set includes the following:
- `allow-auth-with-safari`
- `allow-auth-with-custom-tab`
- `allow-copy-uri-to-path`
- `allow-use-background-audio`
## Permission Table
@@ -13,6 +18,32 @@ Default permissions for the plugin
</tr>
<tr>
<td>
`native-bridge:allow-auth-with-custom-tab`
</td>
<td>
Enables the auth_with_custom_tab command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-auth-with-custom-tab`
</td>
<td>
Denies the auth_with_custom_tab command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
@@ -36,6 +67,58 @@ Enables the auth_with_safari command without any pre-configured scope.
Denies the auth_with_safari command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-copy-uri-to-path`
</td>
<td>
Enables the copy_uri_to_path command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-copy-uri-to-path`
</td>
<td>
Denies the copy_uri_to_path command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-use-background-audio`
</td>
<td>
Enables the use_background_audio command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-use-background-audio`
</td>
<td>
Denies the use_background_audio command without any pre-configured scope.
</td>
</tr>
</table>
@@ -1,3 +1,3 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari"]
permissions = ["allow-auth-with-safari", "allow-auth-with-custom-tab", "allow-copy-uri-to-path", "allow-use-background-audio"]
@@ -294,20 +294,59 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the auth_with_custom_tab command without any pre-configured scope.",
"type": "string",
"const": "allow-auth-with-custom-tab",
"markdownDescription": "Enables the auth_with_custom_tab command without any pre-configured scope."
},
{
"description": "Denies the auth_with_custom_tab command without any pre-configured scope.",
"type": "string",
"const": "deny-auth-with-custom-tab",
"markdownDescription": "Denies the auth_with_custom_tab command without any pre-configured scope."
},
{
"description": "Enables the auth_with_safari command without any pre-configured scope.",
"type": "string",
"const": "allow-auth-with-safari"
"const": "allow-auth-with-safari",
"markdownDescription": "Enables the auth_with_safari command without any pre-configured scope."
},
{
"description": "Denies the auth_with_safari command without any pre-configured scope.",
"type": "string",
"const": "deny-auth-with-safari"
"const": "deny-auth-with-safari",
"markdownDescription": "Denies the auth_with_safari command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin",
"description": "Enables the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
"const": "default"
"const": "allow-copy-uri-to-path",
"markdownDescription": "Enables the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Denies the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
"const": "deny-copy-uri-to-path",
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Enables the use_background_audio command without any pre-configured scope.",
"type": "string",
"const": "allow-use-background-audio",
"markdownDescription": "Enables the use_background_audio command without any pre-configured scope."
},
{
"description": "Denies the use_background_audio command without any pre-configured scope.",
"type": "string",
"const": "deny-use-background-audio",
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`"
}
]
}
@@ -7,7 +7,31 @@ use crate::Result;
#[command]
pub(crate) async fn auth_with_safari<R: Runtime>(
app: AppHandle<R>,
payload: SafariAuthRequest,
) -> Result<SafariAuthResponse> {
payload: AuthRequest,
) -> Result<AuthResponse> {
app.native_bridge().auth_with_safari(payload)
}
#[command]
pub(crate) async fn auth_with_custom_tab<R: Runtime>(
app: AppHandle<R>,
payload: AuthRequest,
) -> Result<AuthResponse> {
app.native_bridge().auth_with_custom_tab(payload)
}
#[command]
pub(crate) async fn copy_uri_to_path<R: Runtime>(
app: AppHandle<R>,
payload: CopyURIRequest,
) -> Result<CopyURIResponse> {
app.native_bridge().copy_uri_to_path(payload)
}
#[command]
pub(crate) async fn use_background_audio<R: Runtime>(
app: AppHandle<R>,
payload: UseBackgroundAudioRequest,
) -> Result<()> {
app.native_bridge().use_background_audio(payload)
}
@@ -14,10 +14,19 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
pub struct NativeBridge<R: Runtime>(AppHandle<R>);
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(
&self,
_payload: SafariAuthRequest,
) -> crate::Result<SafariAuthResponse> {
pub fn auth_with_safari(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn auth_with_custom_tab(&self, _payload: AuthRequest) -> crate::Result<AuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn copy_uri_to_path(&self, _payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -36,7 +36,12 @@ impl<R: Runtime, T: Manager<R>> crate::NativeBridgeExt<R> for T {
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("native-bridge")
.invoke_handler(tauri::generate_handler![commands::auth_with_safari])
.invoke_handler(tauri::generate_handler![
commands::auth_with_safari,
commands::auth_with_custom_tab,
commands::copy_uri_to_path,
commands::use_background_audio,
])
.setup(|app, api| {
#[cfg(mobile)]
let native_bridge = mobile::init(app, api)?;
@@ -25,12 +25,33 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
pub struct NativeBridge<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_safari(
&self,
payload: SafariAuthRequest,
) -> crate::Result<SafariAuthResponse> {
pub fn auth_with_safari(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
self.0
.run_mobile_plugin("auth_with_safari", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn auth_with_custom_tab(&self, payload: AuthRequest) -> crate::Result<AuthResponse> {
self.0
.run_mobile_plugin("auth_with_custom_tab", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn copy_uri_to_path(&self, payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
self.0
.run_mobile_plugin("copy_uri_to_path", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> {
self.0
.run_mobile_plugin("use_background_audio", payload)
.map_err(Into::into)
}
}
@@ -2,12 +2,32 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthRequest {
pub struct AuthRequest {
pub auth_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthResponse {
pub struct AuthResponse {
pub redirect_url: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CopyURIRequest {
pub uri: String,
pub dst: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CopyURIResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UseBackgroundAudioRequest {
pub enabled: bool,
}
+5 -8
View File
@@ -217,14 +217,11 @@ pub fn run() {
app.deep_link().register_all()?;
}
#[cfg(desktop)]
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
+15 -8
View File
@@ -23,7 +23,7 @@ import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oau
import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithSafari } from './utils/safariAuth';
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
import { READEST_WEB_BASE_URL } from '@/services/constants';
import WindowButtons from '@/components/WindowButtons';
@@ -42,7 +42,7 @@ interface ProviderLoginProp {
}
const WEB_AUTH_CALLBACK = `${READEST_WEB_BASE_URL}/auth/callback`;
const DEEPLINK_CALLBACK = 'readest://auth/callback';
const DEEPLINK_CALLBACK = 'readest://auth-callback';
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
return (
@@ -75,10 +75,8 @@ export default function AuthPage() {
const getTauriRedirectTo = (isOAuth: boolean) => {
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
if (appService?.isIOSApp) {
if (appService?.isMobile) {
return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK;
} else if (appService?.isAndroidApp) {
return WEB_AUTH_CALLBACK;
}
return DEEPLINK_CALLBACK;
}
@@ -135,6 +133,11 @@ export default function AuthPage() {
if (res) {
handleOAuthUrl(res.redirectUrl);
}
} else if (appService?.isAndroidApp) {
const res = await authWithCustomTab({ authUrl: data.url });
if (res) {
handleOAuthUrl(res.redirectUrl);
}
} else {
await openUrl(data.url);
}
@@ -304,14 +307,15 @@ export default function AuthPage() {
return isTauriAppPlatform() ? (
<div
className={clsx(
'bg-base-100 border-base-200 flex h-dvh w-full select-none flex-col items-center border',
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
'bg-base-100 border-base-200 border',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
>
<div
ref={headerRef}
className={clsx(
'fixed flex w-full items-center justify-between py-2 pe-6 ps-4',
'fixed z-10 flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
)}
>
@@ -329,7 +333,10 @@ export default function AuthPage() {
/>
)}
</div>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<div
className={clsx('z-20 pb-8', appService?.hasTrafficLight ? 'mt-24' : 'mt-12')}
style={{ maxWidth: '420px' }}
>
<ProviderLogin
provider='google'
handleSignIn={tauriSignIn}
@@ -0,0 +1,25 @@
import { invoke } from '@tauri-apps/api/core';
export interface AuthRequest {
authUrl: string;
}
export interface AuthResponse {
redirectUrl: string;
}
export async function authWithSafari(request: AuthRequest): Promise<AuthResponse> {
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_safari', {
payload: request,
});
return result;
}
export async function authWithCustomTab(request: AuthRequest): Promise<AuthResponse> {
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_custom_tab', {
payload: request,
});
return result;
}
@@ -1,17 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
export interface SafariAuthRequest {
authUrl: string;
}
export interface SafariAuthResponse {
redirectUrl: string;
}
export async function authWithSafari(request: SafariAuthRequest): Promise<SafariAuthResponse> {
const result = await invoke<SafariAuthResponse>('plugin:native-bridge|auth_with_safari', {
payload: request,
});
return result;
}
@@ -10,6 +10,7 @@ import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { formatAuthors, formatTitle } from '@/utils/book';
import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
@@ -49,6 +50,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [queryTerm, setQueryTerm] = useState<string | null>(null);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const isImportingBook = useRef(false);
@@ -86,6 +88,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
if (query) {
setQueryTerm(query);
} else {
setQueryTerm(null);
}
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
@@ -93,11 +101,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
if (booksGroup) {
setNavBooksGroup(booksGroup);
} else {
navigateToLibrary(router);
navigateToLibrary(router, query ? `q=${query}` : undefined);
}
} else {
setNavBooksGroup(null);
navigateToLibrary(router);
navigateToLibrary(router, query ? `q=${query}` : undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
@@ -137,6 +145,18 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const bookFilter = (item: Book, queryTerm: string) => {
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return searchTerm.test(title) || searchTerm.test(authors);
};
const filteredBookshelfItems = currentBookshelfItems.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
return true;
});
return (
<div className='bookshelf'>
@@ -146,7 +166,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
)}
>
{currentBookshelfItems.map((item, index) => (
{filteredBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
item={item}
@@ -210,7 +230,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
>
<LuFolderPlus />
<div>{_('Add to Group')}</div>
<div>{_('Group')}</div>
</button>
<button
onClick={deleteSelectedBooks}
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
@@ -94,6 +94,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
@@ -133,7 +134,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (isSelectMode) {
toggleSelection(group.id);
} else {
navigateToLibrary(router, `group=${group.id}`);
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
navigateToLibrary(router, `${params.toString()}`);
}
};
@@ -1,17 +1,18 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { MdOutlineMenu } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { throttle } from '@/utils/throttle';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
@@ -40,6 +41,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
@@ -49,6 +51,26 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledUpdateQueryParam = useCallback(
throttle((value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value) {
params.set('q', value);
} else {
params.delete('q');
}
router.push(`?${params.toString()}`);
}, 1000),
[searchParams],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
throttledUpdateQueryParam(newQuery);
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
@@ -93,7 +115,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</span>
<input
type='text'
value={searchQuery}
placeholder={_('Search Books...')}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
@@ -103,6 +127,19 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
{searchQuery && (
<button
type='button'
onClick={() => {
setSearchQuery('');
throttledUpdateQueryParam('');
}}
className='text-gray-400 hover:text-gray-600'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
</button>
)}
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
@@ -14,7 +14,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { QuotaType } from '@/types/user';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -32,6 +32,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
@@ -66,6 +67,15 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const toggleAlwaysOnTop = () => {
settings.alwaysOnTop = !settings.alwaysOnTop;
setSettings(settings);
saveSettings(envConfig, settings);
setIsAlwaysOnTop(settings.alwaysOnTop);
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
setIsDropdownOpen?.(false);
};
const toggleAutoUploadBooks = () => {
settings.autoUpload = !settings.autoUpload;
setSettings(settings);
@@ -174,13 +184,20 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleAutoCheckUpdates}
/>
)}
<hr className='border-base-200 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
{appService?.hasWindow && (
<MenuItem
label={_('Always on Top')}
icon={isAlwaysOnTop ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleAlwaysOnTop}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleScreenWakeLock}
/>
<hr className='border-base-200 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
+34 -14
View File
@@ -8,7 +8,7 @@ import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/naviga
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToLogin, navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { getFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
@@ -31,7 +31,7 @@ import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { tauriQuitApp } from '@/utils/window';
import { tauriHandleSetAlwaysOnTop, tauriQuitApp } from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
@@ -104,6 +104,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
await checkForAppUpdates(_);
}
};
if (settings.alwaysOnTop) {
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
}
doCheckAppUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
@@ -161,9 +164,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useEffect(() => {
const libraryPage = document.querySelector('.library-page');
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
if (!appService?.isMobile) {
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
}
if (isTauriAppPlatform()) {
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
@@ -182,9 +187,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
return () => {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
if (!appService?.isMobile) {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageRef.current]);
@@ -296,6 +303,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const importBooks = async (files: (string | File)[]) => {
setLoading(true);
const failedFiles = [];
const errorMap: [string, string][] = [
['No chapters detected.', _('No chapters detected.')],
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
['Unsupported format.', _('This book format is not supported.')],
];
for (const file of files) {
try {
const book = await appService?.importBook(file, libraryBooks);
@@ -306,12 +318,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getBaseFilename(filename);
const baseFilename = getFilename(filename);
failedFiles.push(baseFilename);
const errorMessage =
error instanceof Error
? errorMap.find(([substring]) => error.message.includes(substring))?.[1] || ''
: '';
eventDispatcher.dispatch('toast', {
message: _('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}),
message:
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}) + (errorMessage ? `\n${errorMessage}` : ''),
type: 'error',
});
console.error('Failed to import book:', filename, error);
@@ -322,7 +339,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const selectFilesTauri = async () => {
return appService?.selectFiles('Select Books', SUPPORTED_FILE_EXTS);
const exts = appService?.isAndroidApp ? [] : SUPPORTED_FILE_EXTS;
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
// Cannot filter out files on Android since some content providers may not return the file name
return files;
};
const selectFilesWeb = () => {
@@ -443,7 +463,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
let files;
if (isTauriAppPlatform()) {
if (appService?.isMobile) {
if (appService?.isIOSApp) {
files = (await selectFilesWeb()) as [File];
} else {
files = (await selectFilesTauri()) as [string];
@@ -28,7 +28,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { isSideBarVisible, sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
@@ -68,7 +68,10 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className={`${appService?.hasRoundedWindow ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
className={clsx(
'relative h-full w-full overflow-hidden',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
)}
>
{isBookmarked && <Ribbon width={`${horizontalGapPercent}%`} />}
<HeaderBar
@@ -80,7 +83,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.vertical && viewSettings.scrolled && (
<>
<div
@@ -138,6 +140,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
/>
)}
<Annotator bookKey={bookKey} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
<FooterBar
bookKey={bookKey}
bookFormat={book.format}
@@ -9,7 +9,7 @@ import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { getStyles, mountAdditionalFonts, transformStylesheet } from '@/utils/style';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
@@ -23,6 +23,7 @@ import {
handleTouchEnd,
} from '../utils/iframeEventHandlers';
import { getMaxInlineSize } from '@/utils/config';
import { transformContent } from '@/services/transformService';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -53,9 +54,31 @@ const FoliateViewer: React.FC<{
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const docTransformHandler = (event: Event) => {
const { detail } = event as CustomEvent;
detail.data = Promise.resolve(detail.data)
.then((data) => {
const viewSettings = getViewSettings(bookKey);
if (detail.type === 'text/css') return transformStylesheet(data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
viewSettings,
content: data,
};
return Promise.resolve(transformContent(ctx));
}
return data;
})
.catch((e) => {
console.error(new Error(`Failed to load ${detail.name}`, { cause: e }));
return '';
});
};
const docLoadHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc loaded:', detail);
console.log('doc index loaded:', detail.index);
if (detail.doc) {
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
const viewSettings = getViewSettings(bookKey)!;
@@ -154,6 +177,9 @@ const FoliateViewer: React.FC<{
viewRef.current = view;
setFoliateView(bookKey, view);
const { book } = view;
book.transformTarget?.addEventListener('data', docTransformHandler);
view.renderer.setStyles?.(getStyles(viewSettings));
const isScrolled = viewSettings.scrolled!;
@@ -13,10 +13,12 @@ import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
import { useSidebarStore } from '@/store/sidebarStore';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { isSideBarVisible } = useSidebarStore();
const { getVisibleLibrary, setLibrary } = useLibraryStore();
const isInitiating = useRef(false);
@@ -45,7 +47,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none`,
appService?.hasRoundedWindow && 'rounded-window',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense>
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import Popup from '@/components/Popup';
import PopupButton from './PopupButton';
@@ -8,6 +9,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface AnnotationPopupProps {
dir: 'ltr' | 'rtl';
isVertical: boolean;
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
position: Position;
trianglePosition: Position;
@@ -24,6 +26,7 @@ const OPTIONS_PADDING_PIX = 16;
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
dir,
isVertical,
buttons,
position,
trianglePosition,
@@ -39,17 +42,20 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
return (
<div dir={dir}>
<Popup
width={popupWidth}
height={popupHeight}
width={isVertical ? popupHeight : popupWidth}
height={isVertical ? popupWidth : popupHeight}
position={position}
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 px-4 text-white'
className='selection-popup bg-gray-600 text-white'
triangleClassName='text-gray-600'
>
<div
className='selection-buttons flex items-center justify-between'
className={clsx(
'selection-buttons flex items-center justify-between p-2',
isVertical ? 'flex-col' : 'flex-row',
)}
style={{
height: popupHeight,
height: isVertical ? popupWidth : popupHeight,
}}
>
{buttons.map((button, index) => (
@@ -65,15 +71,27 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
</Popup>
{highlightOptionsVisible && (
<HighlightOptions
isVertical={isVertical}
style={{
width: `${popupWidth}px`,
height: `${popupHeight}px`,
left: `${position.point.x}px`,
top: `${
position.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'up' ? -1 : 1)
}px`,
width: `${isVertical ? popupHeight : popupWidth}px`,
height: `${isVertical ? popupWidth : popupHeight}px`,
...(isVertical
? {
left: `${
position.point.x +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'left' ? -1 : 1)
}px`,
top: `${position.point.y}px`,
}
: {
left: `${position.point.x}px`,
top: `${
position.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}),
}}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
@@ -259,8 +259,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const annotPopupPos = getPopupPosition(
triangPos,
rect,
annotPopupWidth,
annotPopupHeight,
viewSettings.vertical ? annotPopupHeight : annotPopupWidth,
viewSettings.vertical ? annotPopupWidth : annotPopupHeight,
popupPadding,
);
if (isTextSelected.current && annotPopupPos.dir === 'down' && osPlatform === 'android') {
@@ -406,11 +406,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (!selection || !selection.text) return;
const { sectionHref: href } = progress;
selection.href = href;
setShowAnnotPopup(false);
setHighlightOptionsVisible(false);
handleHighlight(true);
setNotebookVisible(true);
setNotebookNewAnnotation(selection);
handleHighlight(true);
handleDismissPopup();
};
const handleSearch = () => {
@@ -578,6 +577,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
{showAnnotPopup && trianglePosition && annotPopupPosition && (
<AnnotationPopup
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
isVertical={viewSettings.vertical}
buttons={buttons}
position={annotPopupPosition}
trianglePosition={trianglePosition}
@@ -21,9 +21,7 @@ const LANGUAGES = {
RU: 'Russian',
AR: 'Arabic',
TR: 'Turkish',
HI: 'Hindi',
ID: 'Indonesian',
VI: 'Vietnamese',
KO: 'Korean',
JA: 'Japanese',
'ZH-HANS': 'Chinese (Simplified)',
@@ -105,7 +103,11 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
setTranslation(translatedText);
} catch (err) {
console.error(err);
setError(_('Unable to fetch the translation. Try again later.'));
if (!token) {
setError(_('Unable to fetch the translation. Please log in first and try again.'));
} else {
setError(_('Unable to fetch the translation. Try again later.'));
}
} finally {
setLoading(false);
}
@@ -9,6 +9,7 @@ const styles = ['highlight', 'underline', 'squiggly'] as HighlightStyle[];
const colors = ['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[];
interface HighlightOptionsProps {
isVertical: boolean;
style: React.CSSProperties;
selectedStyle: HighlightStyle;
selectedColor: HighlightColor;
@@ -17,6 +18,7 @@ interface HighlightOptionsProps {
const HighlightOptions: React.FC<HighlightOptionsProps> = ({
style,
isVertical,
selectedStyle: _selectedStyle,
selectedColor: _selectedColor,
onHandleHighlight,
@@ -44,13 +46,22 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
onHandleHighlight(true);
};
return (
<div className='highlight-options absolute flex items-center justify-between' style={style}>
<div className='flex gap-2' style={{ height: size28 }}>
<div
className={clsx(
'highlight-options absolute flex items-center justify-between',
isVertical ? 'flex-col' : 'flex-row',
)}
style={style}
>
<div
className={clsx('flex gap-2', isVertical ? 'flex-col' : 'flex-row')}
style={isVertical ? { width: size28 } : { height: size28 }}
>
{styles.map((style) => (
<button
key={style}
onClick={() => handleSelectStyle(style)}
className={`flex items-center justify-center rounded-full bg-gray-700 p-0`}
className='flex items-center justify-center rounded-full bg-gray-700 p-0'
style={{ width: size28, height: size28, minHeight: size28 }}
>
<div
@@ -78,8 +89,11 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
</div>
<div
className='flex items-center justify-center gap-2 rounded-3xl bg-gray-700 px-2'
style={{ height: size28 }}
className={clsx(
'flex items-center justify-center gap-2 rounded-3xl bg-gray-700',
isVertical ? 'flex-col py-2' : 'flex-row px-2',
)}
style={isVertical ? { width: size28 } : { height: size28 }}
>
{colors.map((color) => (
<button
@@ -20,7 +20,7 @@ const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Ico
>
<button
onClick={handleClick}
className='my-2 flex h-8 min-h-8 w-8 items-center justify-center p-0'
className='flex h-8 min-h-8 w-8 items-center justify-center p-0'
>
<Icon />
</button>
@@ -41,7 +41,10 @@ const FontDropdown: React.FC<DropdownProps> = ({
</button>
<ul
tabIndex={0}
className='dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-[-32px] z-[1] mt-4 w-44 shadow sm:right-0'
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-[-32px] z-[1] mt-4 w-44 shadow sm:right-0',
'inline max-h-80 overflow-y-scroll',
)}
>
{options.map(({ option, label }) => (
<li key={option} onClick={() => onSelect(option)}>
@@ -3,6 +3,8 @@ import React, { useEffect, useState } from 'react';
import {
ANDROID_FONTS,
CJK_FONTS_PATTENS,
CJK_NAMES_PATTENS,
IOS_FONTS,
LINUX_FONTS,
MACOS_FONTS,
@@ -14,7 +16,7 @@ import {
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { getOSPlatform } from '@/utils/misc';
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
import { getSysFontsList } from '@/utils/font';
import { isTauriAppPlatform } from '@/services/environment';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
@@ -63,8 +65,9 @@ const FontFace = ({
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getViewSettings } = useReaderStore();
const { getView, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const view = getView(bookKey)!;
const fontFamilyOptions = [
{
@@ -103,6 +106,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [minFontSize, setMinFontSize] = useState(viewSettings.minimumFontSize!);
const [overrideFont, setOverrideFont] = useState(viewSettings.overrideFont!);
const [defaultFont, setDefaultFont] = useState(viewSettings.defaultFont!);
const [defaultCJKFont, setDefaultCJKFont] = useState(viewSettings.defaultCJKFont!);
const [serifFont, setSerifFont] = useState(viewSettings.serifFont!);
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
@@ -122,6 +126,11 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFont]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'defaultCJKFont', defaultCJKFont);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultCJKFont]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', defaultFontSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -224,6 +233,19 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
</div>
{(isCJKEnv() || view?.language.isCJK) && (
<FontFace
className='config-item-top'
family='serif'
label={_('CJK Font')}
options={sysFonts.filter(
(font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font),
)}
selected={defaultCJKFont}
onSelect={setDefaultCJKFont}
/>
)}
<div className='config-item'>
<span className=''>{_('Override Book Font')}</span>
<input
@@ -122,7 +122,9 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (writingMode === viewSettings.writingMode) return;
// global settings are not supported for writing mode
const prevWritingMode = viewSettings.writingMode;
if (!writingMode.includes('vertical')) {
if (writingMode.includes('vertical')) {
viewSettings.vertical = true;
} else {
viewSettings.vertical = false;
}
saveViewSettings(envConfig, bookKey, 'writingMode', writingMode, true);
@@ -90,7 +90,9 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleInputBlur = () => {
if (appService?.isAndroidApp) {
setInputFocusInAndroid(false);
setTimeout(() => {
setInputFocusInAndroid(false);
}, 100);
}
};
@@ -145,7 +147,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return (
<div
className={clsx('my-4 w-full space-y-6', inputFocusInAndroid && 'h-[50%] overflow-y-auto')}
className={clsx(
'my-4 w-full space-y-6',
inputFocusInAndroid && 'h-[50%] overflow-y-auto pb-[200px]',
)}
>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
@@ -11,6 +11,7 @@ import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -33,21 +34,42 @@ const TTSControl = () => {
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 popupWidth = useResponsiveSize(POPUP_WIDTH);
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const popupPadding = useResponsiveSize(POPUP_PADDING);
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
// this enables WebAudio to play even when the mute toggle switch is ON
const unblockAudio = () => {
const audio = document.createElement('audio');
audio.setAttribute('x-webkit-airplay', 'deny');
audio.preload = 'auto';
audio.loop = true;
audio.src = SILENCE_DATA;
audio.play();
if (unblockerAudioRef.current) return;
unblockerAudioRef.current = document.createElement('audio');
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
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);
}
};
useEffect(() => {
@@ -94,6 +116,9 @@ const TTSControl = () => {
setShowIndicator(true);
try {
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: true });
}
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
unblockAudio();
}
@@ -121,11 +146,8 @@ const TTSControl = () => {
}
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey: stopBookKey } = event.detail;
if (bookKey === stopBookKey) {
handleStop();
}
const handleTTSStop = async () => {
handleStop();
};
const handleQueryIsSpeaking = () => {
@@ -177,6 +199,12 @@ const TTSControl = () => {
setShowPanel(false);
setShowIndicator(false);
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
releaseUnblockAudio();
}
};
// rate range: 0.5 - 3, 1.0 is normal speed
@@ -230,6 +258,23 @@ const TTSControl = () => {
return '';
};
const handleSelectTimeout = (value: number) => {
setTimeoutOption(value);
if (timeoutFunc) {
clearTimeout(timeoutFunc);
}
if (value > 0) {
setTimeoutFunc(
setTimeout(() => {
handleStop();
}, value * 1000),
);
setTimeoutTimestamp(Date.now() + value * 1000);
} else {
setTimeoutTimestamp(0);
}
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
@@ -277,8 +322,8 @@ const TTSControl = () => {
className={clsx(
'fixed right-6 h-12 w-12',
appService?.hasSafeAreaInset
? 'bottom-[calc(env(safe-area-inset-bottom)+48px)]'
: 'bottom-12',
? 'bottom-[calc(env(safe-area-inset-bottom)+70px)]'
: 'bottom-[70px] sm:bottom-14',
)}
>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
@@ -296,14 +341,16 @@ const TTSControl = () => {
bookKey={bookKey}
ttsLang={ttsLang}
isPlaying={isPlaying}
timeoutOption={timeoutOption}
timeoutTimestamp={timeoutTimestamp}
onTogglePlay={handleTogglePlay}
onBackward={handleBackward}
onForward={handleForward}
onStop={handleStop}
onSetRate={handleSetRate}
onGetVoices={handleGetVoices}
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
onSelectTimeout={handleSelectTimeout}
/>
</Popup>
)}
@@ -1,12 +1,12 @@
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoice } from '@/services/tts';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -14,28 +14,104 @@ type TTSPanelProps = {
bookKey: string;
ttsLang: string;
isPlaying: boolean;
timeoutOption: number;
timeoutTimestamp: number;
onTogglePlay: () => void;
onBackward: () => void;
onForward: () => void;
onStop: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (value: number) => void;
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
return [
{
label: _('No Timeout'),
value: 0,
},
{
label: _('{{value}} minute', { value: 1 }),
value: 60,
},
{
label: _('{{value}} minutes', { value: 3 }),
value: 180,
},
{
label: _('{{value}} minutes', { value: 5 }),
value: 300,
},
{
label: _('{{value}} minutes', { value: 10 }),
value: 600,
},
{
label: _('{{value}} minutes', { value: 20 }),
value: 1200,
},
{
label: _('{{value}} minutes', { value: 30 }),
value: 1800,
},
{
label: _('{{value}} minutes', { value: 45 }),
value: 2700,
},
{
label: _('{{value}} hour', { value: 1 }),
value: 3600,
},
{
label: _('{{value}} hours', { value: 2 }),
value: 7200,
},
{
label: _('{{value}} hours', { value: 3 }),
value: 10800,
},
{
label: _('{{value}} hours', { value: 4 }),
value: 14400,
},
{
label: _('{{value}} hours', { value: 6 }),
value: 21600,
},
{
label: _('{{value}} hours', { value: 8 }),
value: 28800,
},
];
};
const getCountdownTime = (timeout: number) => {
const now = Date.now();
if (timeout > now) {
const remainingTime = Math.floor((timeout - now) / 1000);
const minutes = Math.floor(remainingTime / 3600) * 60 + Math.floor((remainingTime % 3600) / 60);
const seconds = remainingTime % 60;
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
}
return '';
};
const TTSPanel = ({
bookKey,
ttsLang,
isPlaying,
timeoutOption,
timeoutTimestamp,
onTogglePlay,
onBackward,
onForward,
onStop,
onSetRate,
onGetVoices,
onSetVoice,
onGetVoiceId,
onSelectTimeout,
}: TTSPanelProps) => {
const _ = useTranslation();
const { envConfig } = useEnv();
@@ -47,6 +123,10 @@ const TTSPanel = ({
const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0);
const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? '');
const [timeoutCountdown, setTimeoutCountdown] = useState(() => {
return getCountdownTime(timeoutTimestamp);
});
const defaultIconSize = useDefaultIconSize();
const iconSize32 = useResponsiveSize(32);
const iconSize48 = useResponsiveSize(48);
@@ -72,6 +152,23 @@ const TTSPanel = ({
setViewSettings(bookKey, viewSettings);
};
const updateTimeout = (timeout: number) => {
const now = Date.now();
if (timeout > 0 && timeout < now) {
onSelectTimeout(0);
setTimeoutCountdown('');
} else if (timeout > 0) {
setTimeoutCountdown(getCountdownTime(timeout));
}
};
useEffect(() => {
setTimeout(() => {
updateTimeout(timeoutTimestamp);
}, 1000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeoutTimestamp, timeoutCountdown]);
useEffect(() => {
const voiceId = onGetVoiceId();
setSelectedVoice(voiceId);
@@ -87,6 +184,8 @@ const TTSPanel = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsLang]);
const timeoutOptions = getTTSTimeoutOptions(_);
return (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center gap-0.5'>
@@ -132,9 +231,40 @@ const TTSPanel = ({
<button onClick={onForward} className='rounded-full p-1'>
<MdFastForward size={iconSize32} />
</button>
<button onClick={onStop} className='rounded-full p-1'>
<MdStop size={iconSize32} />
</button>
<div className='dropdown dropdown-top'>
<button className='flex flex-col items-center justify-center rounded-full p-1'>
<MdAlarm size={iconSize32} />
{timeoutCountdown && (
<span
className={clsx(
'absolute bottom-0 left-1/2 w-12 translate-x-[-50%] translate-y-[80%] px-1',
'bg-primary/80 text-base-100 rounded-full text-center text-xs',
)}
>
{timeoutCountdown}
</span>
)}
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
)}
>
{timeoutOptions.map((option, index) => (
<li key={`${index}-${option.value}`} onClick={() => onSelectTimeout(option.value)}>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
</span>
<span className={clsx('text-base sm:text-sm')}>{option.label}</span>
</div>
</li>
))}
</ul>
</div>
<div className='dropdown dropdown-top'>
<button tabIndex={0} className='rounded-full p-1'>
<RiVoiceAiFill size={iconSize32} />
@@ -43,7 +43,10 @@ export const useClickEvent = (
if (!consumed) {
const centerStartX = viewStartX + viewRect.width * 0.375;
const centerEndX = viewStartX + viewRect.width * 0.625;
if (screenX >= centerStartX && screenX <= centerEndX) {
if (
viewSettings.disableClick! ||
(screenX >= centerStartX && screenX <= centerEndX)
) {
// toggle visibility of the header bar and the footer bar
setHoveredBookKey(hoveredBookKey ? null : bookKey);
} else {
+3 -2
View File
@@ -159,7 +159,8 @@ const ProfilePage = () => {
return (
<div
className={clsx(
'bg-base-100 border-base-200 flex h-dvh w-full select-none flex-col items-center border',
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
'bg-base-100 border-base-200 border',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
>
@@ -184,7 +185,7 @@ const ProfilePage = () => {
/>
)}
</div>
<div className='w-full max-w-4xl px-4 py-10'>
<div className='w-full min-w-60 max-w-4xl px-4 py-10'>
<div className='bg-base-200 overflow-hidden rounded-lg p-2 shadow-md sm:p-6'>
<div className='p-2 sm:p-6'>
<div className='mb-8 flex flex-col items-center gap-x-6 gap-y-4 md:flex-row md:items-start'>
+14 -14
View File
@@ -21,7 +21,20 @@ const Popup = ({
}) => (
<div>
<div
className={`triangle text-base-300 absolute z-40 ${triangleClassName}`}
id='popup-container'
className={`bg-base-300 absolute rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
height: `${height}px`,
left: `${position ? position.point.x : -999}px`,
top: `${position ? position.point.y : -999}px`,
...additionalStyle,
}}
>
{children}
</div>
<div
className={`triangle text-base-300 absolute ${triangleClassName}`}
style={{
left:
trianglePosition?.dir === 'left'
@@ -65,19 +78,6 @@ const Popup = ({
: 'translateX(-50%)',
}}
/>
<div
id='popup-container'
className={`bg-base-300 absolute z-30 rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
height: `${height}px`,
left: `${position ? position.point.x : -999}px`,
top: `${position ? position.point.y : -999}px`,
...additionalStyle,
}}
>
{children}
</div>
</div>
);
+6 -1
View File
@@ -69,7 +69,12 @@ export const Toast = () => {
messageClass.current,
)}
>
{toastMessage}
{toastMessage.split('\n').map((line, idx) => (
<React.Fragment key={idx}>
{line || <>&nbsp;</>}
<br />
</React.Fragment>
))}
</span>
</div>
</div>
+1
View File
@@ -66,6 +66,7 @@ export interface BookDoc {
dir: string;
toc?: Array<TOCItem>;
sections?: Array<SectionItem>;
transformTarget?: EventTarget;
splitTOCHref(href: string): Array<string | number>;
getCover(): Promise<Blob | null>;
}
+5 -7
View File
@@ -143,8 +143,7 @@ const hashPayload = (payload: EdgeTTSPayload): string => {
export class EdgeSpeechTTS {
static voices = genVoiceList(EDGE_TTS_VOICES);
private static audioCache = new LRUCache<string, AudioBuffer>(200);
private audioContext = new AudioContext();
private static audioCache = new LRUCache<string, ArrayBuffer>(200);
constructor() {}
@@ -267,17 +266,16 @@ export class EdgeSpeechTTS {
return this.#fetchEdgeSpeechWs(payload);
}
async createAudio(payload: EdgeTTSPayload): Promise<AudioBuffer> {
async createAudio(payload: EdgeTTSPayload): Promise<Blob> {
const cacheKey = hashPayload(payload);
if (EdgeSpeechTTS.audioCache.has(cacheKey)) {
return EdgeSpeechTTS.audioCache.get(cacheKey)!;
return new Blob([EdgeSpeechTTS.audioCache.get(cacheKey)!], { type: 'audio/mpeg' });
}
try {
const res = await this.create(payload);
const arrayBuffer = await res.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer.slice(0));
EdgeSpeechTTS.audioCache.set(cacheKey, audioBuffer);
return audioBuffer;
EdgeSpeechTTS.audioCache.set(cacheKey, arrayBuffer);
return new Blob([arrayBuffer], { type: 'audio/mpeg' });
} catch (error) {
throw error;
}
@@ -4,8 +4,8 @@ import { supabase } from '@/utils/supabase';
import { getUserPlan } from '@/utils/access';
import { query as deeplQuery } from '@/utils/deepl';
const DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
@@ -26,34 +26,46 @@ const getDeepLAPIKey = (keys: string | undefined) => {
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await runMiddleware(req, res, corsAllMethods);
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { user, token } = await getUserAndToken(req.headers['authorization']);
let deeplApiUrl = DEEPL_FREE_API;
const { DEEPL_PRO_API, DEEPL_FREE_API } = process.env;
const deepFreeApiUrl = DEEPL_FREE_API || DEFAULT_DEEPL_FREE_API;
const deeplProApiUrl = DEEPL_PRO_API || DEFAULT_DEEPL_PRO_API;
let deeplApiUrl = deepFreeApiUrl;
let userPlan = 'free';
if (user && token) {
userPlan = getUserPlan(token);
if (userPlan !== 'free') deeplApiUrl = DEEPL_PRO_API;
if (userPlan === 'pro') deeplApiUrl = deeplProApiUrl;
} else {
res.status(403).json({ error: 'Not authenticated' });
}
const deeplAuthKey =
deeplApiUrl === DEEPL_PRO_API
deeplApiUrl === deeplProApiUrl
? getDeepLAPIKey(process.env['DEEPL_PRO_API_KEYS'])
: getDeepLAPIKey(process.env['DEEPL_FREE_API_KEYS']);
await runMiddleware(req, res, corsAllMethods);
const {
text,
source_lang: sourceLang = 'auto',
target_lang: targetLang = 'en',
}: { text: string[]; source_lang: string; target_lang: string } = req.body;
try {
if (targetLang.toLowerCase().includes('zh')) {
if (user && token) {
console.log('deeplApiUrl', deeplApiUrl);
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'x-fingerprint': process.env['DEEPL_X_FINGERPRINT'] || '',
'Content-Type': 'application/json',
},
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
body: JSON.stringify(req.body),
});
res.status(response.status);
res.json(await response.json());
+72 -36
View File
@@ -1,4 +1,4 @@
import { AppPlatform, AppService, ToastType } from '@/types/system';
import { AppPlatform, AppService } from '@/types/system';
import { SystemSettings } from '@/types/settings';
import { FileSystem, BaseDir } from '@/types/system';
@@ -14,8 +14,8 @@ import {
INIT_BOOK_CONFIG,
formatTitle,
formatAuthors,
getFilename,
} from '@/utils/book';
import { RemoteFile } from '@/utils/file';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader } from '@/libs/document';
import {
@@ -32,9 +32,10 @@ import {
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
} from './constants';
import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
import { ClosableFile } from '@/utils/file';
import { ProgressHandler } from '@/utils/transfer';
import { TxtToEpubConverter } from '@/utils/txt';
import { BOOK_FILE_NOT_FOUND_ERROR } from './errors';
@@ -61,13 +62,9 @@ export abstract class BaseAppService implements AppService {
abstract getCoverImageUrl(book: Book): string;
abstract getCoverImageBlobUrl(book: Book): Promise<string>;
abstract getInitBooksDir(): Promise<string>;
abstract getCacheDir(): Promise<string>;
abstract selectDirectory(): Promise<string>;
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
abstract showMessage(
msg: string,
kind?: ToastType,
title?: string,
okLabel?: string,
): Promise<void>;
async loadSettings(): Promise<SystemSettings> {
let settings: SystemSettings;
@@ -117,6 +114,15 @@ export abstract class BaseAppService implements AppService {
}
this.localBooksDir = settings.localBooksDir;
const cacheDir = await this.getCacheDir();
this.fs.getPrefix = (baseDir: BaseDir) => {
if (baseDir === 'Books') {
return this.localBooksDir;
} else if (baseDir === 'Cache') {
return cacheDir;
}
return null;
};
return settings;
}
@@ -127,7 +133,12 @@ export abstract class BaseAppService implements AppService {
}
async importBook(
file: string | File, // file path/url or file object
// file might be:
// 1. absolute path for local file
// 2. remote url
// 3. content provider uri
// 4. File object from browsers
file: string | File,
books: Book[],
saveBook: boolean = true,
saveCover: boolean = true,
@@ -140,10 +151,14 @@ export abstract class BaseAppService implements AppService {
let filename: string;
let fileobj: File;
if (transient && typeof file !== 'string') {
throw new Error('Transient import is only supported for file paths');
}
try {
if (typeof file === 'string') {
filename = file;
fileobj = await new RemoteFile(this.fs.getURL(file), file).open();
filename = getFilename(file);
fileobj = await this.fs.openFile(file, 'None');
} else {
filename = file.name;
fileobj = file;
@@ -181,14 +196,28 @@ export abstract class BaseAppService implements AppService {
downloadedAt: Date.now(),
updatedAt: Date.now(),
};
// update book metadata when reimporting the same book
if (existingBook) {
existingBook.title = book.title;
existingBook.author = book.author;
}
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
if (saveBook && (!(await this.fs.exists(getLocalBookFilename(book), 'Books')) || overwrite)) {
if (typeof file === 'string' && !isValidURL(file) && !filename.endsWith('.txt')) {
if (
saveBook &&
!transient &&
(!(await this.fs.exists(getLocalBookFilename(book), 'Books')) || overwrite)
) {
if (typeof file === 'string' && isContentURI(file)) {
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} else if (filename.endsWith('.txt')) {
await this.fs.writeFile(getLocalBookFilename(book), 'Books', fileobj);
} else if (typeof file === 'string' && !isValidURL(file)) {
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} else {
await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer());
await this.fs.writeFile(getLocalBookFilename(book), 'Books', fileobj);
}
}
if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) {
@@ -201,15 +230,24 @@ export abstract class BaseAppService implements AppService {
if (!existingBook) {
await this.saveBookConfig(book, INIT_BOOK_CONFIG);
books.splice(0, 0, book);
} else {
existingBook.title = book.title;
existingBook.author = book.author;
}
if (typeof file === 'string' && isValidURL(file)) {
book.url = file;
// update file links with url or path or content uri
if (typeof file === 'string') {
if (isValidURL(file)) {
book.url = file;
if (existingBook) existingBook.url = file;
}
if (transient) {
book.filePath = file;
if (existingBook) existingBook.filePath = file;
}
}
book.coverImageUrl = await this.generateCoverImageUrl(book);
const f = file as ClosableFile;
if (f && f.close) {
await f.close();
}
return book;
} catch (error) {
@@ -244,16 +282,14 @@ export abstract class BaseAppService implements AppService {
}
async uploadFileToCloud(lfp: string, cfp: string, handleProgress: ProgressHandler, hash: string) {
let file: File;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(lfp, 'Books', 'binary');
file = new File([content], cfp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${lfp}`), cfp).open();
}
console.log('Uploading file:', lfp, 'to', cfp);
const file = await this.fs.openFile(lfp, 'Books', cfp);
const localFullpath = `${this.localBooksDir}/${lfp}`;
await uploadFile(file, localFullpath, handleProgress, hash);
const f = file as ClosableFile;
if (f && f.close) {
await f.close();
}
}
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
@@ -270,7 +306,7 @@ export abstract class BaseAppService implements AppService {
}
if (!bookFileExist && book.url) {
// download the book from the URL
const fileobj = await new RemoteFile(book.url).open();
const fileobj = await this.fs.openFile(book.url, 'None');
await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer());
bookFileExist = true;
}
@@ -362,15 +398,11 @@ export abstract class BaseAppService implements AppService {
let file: File;
const fp = getLocalBookFilename(book);
if (await this.fs.exists(fp, 'Books')) {
// TODO: fix random access for android
if (this.appPlatform === 'web' || getOSPlatform() === 'android') {
const content = await this.fs.readFile(fp, 'Books', 'binary');
file = new File([content], fp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
}
file = await this.fs.openFile(fp, 'Books');
} else if (book.filePath) {
file = await this.fs.openFile(book.filePath, 'None');
} else if (book.url) {
file = await new RemoteFile(book.url).open();
file = await this.fs.openFile(book.url, 'None');
} else {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
}
@@ -397,6 +429,10 @@ export abstract class BaseAppService implements AppService {
}
const { file } = (await this.loadBookContent(book, settings)) as BookContent;
const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc;
const f = file as ClosableFile;
if (f && f.close) {
await f.close();
}
return bookDoc.metadata;
}
@@ -34,6 +34,7 @@ export const BOOK_UNGROUPED_ID = '';
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
keepLogin: false,
autoUpload: true,
alwaysOnTop: false,
autoCheckUpdates: true,
screenWakeLock: true,
autoImportBooksOnOpen: false,
@@ -65,6 +66,7 @@ export const DEFAULT_BOOK_FONT: BookFont = {
sansSerifFont: 'Roboto',
monospaceFont: 'Consolas',
defaultFont: 'Serif',
defaultCJKFont: 'LXGW WenKai GB Screen',
defaultFontSize: 16,
minimumFontSize: 8,
fontWeight: 400,
@@ -180,6 +182,7 @@ export const WINDOWS_FONTS = [
'Leelawadee UI',
'Lucida Console',
'Lucida Sans Unicode',
'LXGW WenKai GB Screen',
'Malgun Gothic',
'Marlett',
'Microsoft Himalaya',
@@ -274,6 +277,7 @@ export const MACOS_FONTS = [
'Kozuka Mincho Pro',
'Lucida Grande',
'Luminari',
'LXGW WenKai GB Screen',
'Marker Felt',
'Menlo',
'Microsoft Sans Serif',
@@ -330,6 +334,7 @@ export const LINUX_FONTS = [
'Liberation Mono',
'Liberation Sans',
'Liberation Serif',
'LXGW WenKai GB Screen',
'Noto Mono',
'Noto Sans',
'Noto Sans JP',
@@ -366,6 +371,7 @@ export const IOS_FONTS = [
'Hiragino Mincho',
'Hiragino Sans',
'Kaiti',
'LXGW WenKai GB Screen',
'Palatino',
'PingFang SC',
'PingFang TC',
@@ -388,6 +394,7 @@ export const ANDROID_FONTS = [
'Georgia',
'Heiti',
'Kaiti',
'LXGW WenKai GB Screen',
'Noto Sans',
'Noto Sans CJK',
'Noto Sans JP',
@@ -405,6 +412,47 @@ export const ANDROID_FONTS = [
'XiHeiti',
];
export const CJK_NAMES_PATTENS = /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/;
export const CJK_FONTS_PATTENS = new RegExp(
[
'CJK',
'TC$',
'SC$',
'HK',
'JP',
'TW',
'Sim',
'Kai',
'Hei',
'Yan',
'Min',
'Yuan',
'Song',
'Ming',
'FZ',
'FangZheng',
'WenQuanYi',
'PingFang',
'Hiragino',
'Meiryo',
'Source\\s?Han',
'Yu\\s?Gothic',
'Yu\\s?Mincho',
'Mincho',
'Gothic',
'Nanum',
'Malgun',
'Gulim',
'Dotum',
'Batang',
'Gungsuh',
'OPPO sans',
'MiSans',
'Fallback',
].join('|'),
'i',
);
export const BOOK_IDS_SEPARATOR = '+';
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
+108 -21
View File
@@ -1,6 +1,7 @@
import {
exists,
mkdir,
open as openFile,
readTextFile,
readFile,
writeTextFile,
@@ -9,16 +10,19 @@ import {
remove,
copyFile,
BaseDirectory,
WriteFileOptions,
} from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core';
import { open, message } from '@tauri-apps/plugin-dialog';
import { join, appDataDir } from '@tauri-apps/api/path';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { join, appDataDir, appCacheDir } from '@tauri-apps/api/path';
import { type as osType } from '@tauri-apps/plugin-os';
import { Book } from '@/types/book';
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { isValidURL } from '@/utils/misc';
import { FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { isContentURI, isValidURL } from '@/utils/misc';
import { getCoverFilename, getFilename } from '@/utils/book';
import { copyURIToPath } from '@/utils/bridge';
import { NativeFile, RemoteFile } from '@/utils/file';
import { BaseAppService } from './appService';
import { LOCAL_BOOKS_SUBDIR } from './constants';
@@ -47,6 +51,12 @@ const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDi
fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`,
base,
};
case 'None':
return {
baseDir: 0,
fp,
base,
};
default:
return {
baseDir: BaseDirectory.Temp,
@@ -64,9 +74,60 @@ export const nativeFileSystem: FileSystem = {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
},
async openFile(path: string, base: BaseDir, name?: string) {
const { fp, baseDir } = resolvePath(path, base);
const fname = name || getFilename(fp);
if (isValidURL(path)) {
return await new RemoteFile(path, name).open();
} else if (isContentURI(path)) {
if (path.includes('com.android.externalstorage')) {
// If the URI is from shared internal storage (like /storage/emulated/0),
// we can access it directly using the path — no need to copy.
return await new NativeFile(fp, fname, base ? baseDir : null).open();
} else {
// Otherwise, for content:// URIs (e.g. from MediaStore, Drive, or third-party apps),
// we cannot access the file directly — so we copy it to a temporary cache location.
const prefix = this.getPrefix('Cache');
const dst = `${prefix}/${fname}`;
const res = await copyURIToPath({ uri: path, dst });
if (!res.success) {
console.error('Failed to open file:', res);
throw new Error('Failed to open file');
}
return await new NativeFile(dst, fname, base ? baseDir : null).open();
}
} else {
const prefix = this.getPrefix(base);
if (prefix && OS_TYPE !== 'android') {
// NOTE: RemoteFile currently performs about 2× faster than NativeFile
// due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190).
// Once the bug is resolved, we should switch back to using NativeFile.
// RemoteFile is not usable on Android due to unknown issues of range fetch with Android WebView.
const absolutePath = await join(prefix, path);
return await new RemoteFile(this.getURL(absolutePath), fname).open();
} else {
return await new NativeFile(fp, fname, base ? baseDir : null).open();
}
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(dstPath, base);
await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir });
if (isContentURI(srcPath)) {
const prefix = this.getPrefix(base);
if (!prefix) {
throw new Error('Invalid base directory');
}
const res = await copyURIToPath({
uri: srcPath,
dst: `${prefix}/${dstPath}`,
});
if (!res.success) {
console.error('Failed to copy file:', res);
throw new Error('Failed to copy file');
}
} else {
const { fp, baseDir } = resolvePath(dstPath, base);
await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir });
}
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
const { fp, baseDir } = resolvePath(path, base);
@@ -75,12 +136,32 @@ export const nativeFileSystem: FileSystem = {
? (readTextFile(fp, base && { baseDir }) as Promise<string>)
: ((await readFile(fp, base && { baseDir })).buffer as ArrayBuffer);
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) {
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File) {
// NOTE: this could be very slow for large files and might block the UI thread
// so do not use this for large files
const { fp, baseDir } = resolvePath(path, base);
return typeof content === 'string'
? writeTextFile(fp, content, base && { baseDir })
: writeFile(fp, new Uint8Array(content), base && { baseDir });
if (typeof content === 'string') {
return writeTextFile(fp, content, base && { baseDir });
} else if (content instanceof File) {
const writeOptions = { write: true, create: true, baseDir } as WriteFileOptions;
// TODO: use writeFile directly when @tauri-apps/plugin-fs@2.2.1 is released
// return writeFile(fp, content.stream(), base && writeOptions);
const file = await openFile(fp, base && writeOptions);
const reader = content.stream().getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await file.write(value);
}
} finally {
reader.releaseLock();
await file.close();
}
} else {
return writeFile(fp, new Uint8Array(content), base && { baseDir });
}
},
async removeFile(path: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(path, base);
@@ -118,6 +199,9 @@ export const nativeFileSystem: FileSystem = {
return false;
}
},
getPrefix() {
return null;
},
};
export class NativeAppService extends BaseAppService {
@@ -144,23 +228,26 @@ export class NativeAppService extends BaseAppService {
return join(await appDataDir(), LOCAL_BOOKS_SUBDIR);
}
async getCacheDir(): Promise<string> {
return await appCacheDir();
}
async selectDirectory(): Promise<string> {
const selected = await openDialog({
directory: true,
multiple: false,
});
return selected as string;
}
async selectFiles(name: string, extensions: string[]): Promise<string[]> {
const selected = await open({
const selected = await openDialog({
multiple: true,
filters: [{ name, extensions }],
});
return Array.isArray(selected) ? selected : selected ? [selected] : [];
}
async showMessage(
msg: string,
kind: ToastType = 'info',
title?: string,
okLabel?: string,
): Promise<void> {
await message(msg, { kind, title, okLabel });
}
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`);
};
@@ -0,0 +1,16 @@
import { activeTransformers } from './transformers';
import { TransformContext } from './transformers/types';
export const transformContent = async (ctx: TransformContext): Promise<string> => {
let transformed = ctx.content;
for (const transformer of activeTransformers) {
try {
transformed = await transformer.transform({ ...ctx, content: transformed });
} catch (error) {
console.warn(`Error in transformer ${transformer.name}:`, error);
}
}
return transformed;
};
@@ -0,0 +1,9 @@
import type { Transformer } from './types';
import { translateTransformer } from './translate';
import { punctuationTransformer } from './punctuation';
export const activeTransformers: Transformer[] = [
punctuationTransformer,
translateTransformer,
// Add more transformers here
];
@@ -0,0 +1,26 @@
import type { Transformer } from './types';
const punctuationMap: Record<string, string> = {
'“': '﹃',
'”': '﹄',
'': '﹁',
'': '﹂',
};
export const punctuationTransformer: Transformer = {
name: 'punctuation',
transform: async (ctx) => {
if (!ctx.content.includes('<html')) return ctx.content;
const shouldTransform = ctx.viewSettings.vertical === true;
if (!shouldTransform) return ctx.content;
let result = ctx.content;
for (const [original, vertical] of Object.entries(punctuationMap)) {
result = result.replace(new RegExp(original, 'g'), vertical);
}
return result;
},
};
@@ -0,0 +1,9 @@
import type { Transformer } from './types';
export const translateTransformer: Transformer = {
name: 'translate',
transform: async (ctx) => {
return ctx.content;
},
};
@@ -0,0 +1,12 @@
import { ViewSettings } from '@/types/book';
export type TransformContext = {
bookKey: string;
viewSettings: ViewSettings;
content: string;
};
export type Transformer = {
name: string;
transform: (ctx: TransformContext) => Promise<string>;
};
@@ -13,31 +13,19 @@ export class EdgeTTSClient implements TTSClient {
#voices: TTSVoice[] = [];
#edgeTTS: EdgeSpeechTTS;
static #audioContext: AudioContext | null;
#sourceNode: AudioBufferSourceNode | null = null;
#audioElement: HTMLAudioElement | null = null;
#isPlaying = false;
#pausedAt = 0;
#startedAt = 0;
#audioBuffer: AudioBuffer | null = null;
available = true;
constructor() {
this.#edgeTTS = new EdgeSpeechTTS();
}
async initializeAudioContext() {
if (!EdgeTTSClient.#audioContext) {
EdgeTTSClient.#audioContext = new AudioContext();
}
if (EdgeTTSClient.#audioContext.state === 'suspended') {
await EdgeTTSClient.#audioContext.resume();
}
}
async init() {
this.#voices = EdgeSpeechTTS.voices;
try {
await this.initializeAudioContext();
await this.#edgeTTS.create({
lang: 'en',
text: 'test',
@@ -115,15 +103,12 @@ export class EdgeTTSClient implements TTSClient {
break;
}
try {
this.#audioBuffer = await this.#edgeTTS.createAudio(
this.getPayload(lang, mark.text, voiceId),
);
if (!EdgeTTSClient.#audioContext) {
EdgeTTSClient.#audioContext = new AudioContext();
}
this.#sourceNode = EdgeTTSClient.#audioContext.createBufferSource();
this.#sourceNode.buffer = this.#audioBuffer;
this.#sourceNode.connect(EdgeTTSClient.#audioContext.destination);
const blob = await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId));
const url = URL.createObjectURL(blob);
this.#audioElement = new Audio(url);
const audio = this.#audioElement;
audio.setAttribute('x-webkit-airplay', 'deny');
audio.preload = 'auto';
yield {
code: 'boundary',
@@ -132,36 +117,37 @@ export class EdgeTTSClient implements TTSClient {
};
const result = await new Promise<TTSMessageEvent>((resolve) => {
if (EdgeTTSClient.#audioContext === null || this.#sourceNode === null) {
throw new Error('Audio context or source node is null');
}
this.#sourceNode.onended = (event: Event) => {
// chunk finished speaking or aborted speaking
if (signal.aborted || event.type === 'stopped') {
resolve({
code: 'error',
message: 'Aborted',
});
return;
const cleanUp = () => {
audio.onended = null;
audio.onerror = null;
audio.pause();
audio.src = '';
URL.revokeObjectURL(url);
};
audio.onended = () => {
cleanUp();
if (signal.aborted) {
resolve({ code: 'error', message: 'Aborted' });
} else {
resolve({ code: 'end', message: `Chunk finished: ${mark.name}` });
}
resolve({
code: 'end',
message: `Chunk finished: ${mark.name}`,
});
};
audio.onerror = (e) => {
cleanUp();
console.warn('Audio playback error:', e);
resolve({ code: 'error', message: 'Audio playback error' });
};
if (signal.aborted) {
resolve({
code: 'error',
message: 'Aborted',
});
cleanUp();
resolve({ code: 'error', message: 'Aborted' });
return;
}
if (EdgeTTSClient.#audioContext.state === 'suspended') {
EdgeTTSClient.#audioContext.resume();
}
this.#sourceNode.start(0);
this.#isPlaying = true;
this.#startedAt = EdgeTTSClient.#audioContext.currentTime;
audio.play().catch((err) => {
cleanUp();
console.error('Failed to play audio:', err);
resolve({ code: 'error', message: 'Playback failed: ' + err.message });
});
});
yield result;
} catch (error) {
@@ -186,17 +172,17 @@ export class EdgeTTSClient implements TTSClient {
}
async pause() {
if (!this.#isPlaying || !EdgeTTSClient.#audioContext) return;
this.#pausedAt = EdgeTTSClient.#audioContext.currentTime - this.#startedAt;
await EdgeTTSClient.#audioContext.suspend();
if (!this.#isPlaying || !this.#audioElement) return;
this.#pausedAt = this.#audioElement.currentTime - this.#startedAt;
await this.#audioElement.pause();
this.#isPlaying = false;
}
async resume() {
if (this.#isPlaying || !EdgeTTSClient.#audioContext) return;
await EdgeTTSClient.#audioContext.resume();
if (this.#isPlaying || !this.#audioElement) return;
await this.#audioElement.play();
this.#isPlaying = true;
this.#startedAt = EdgeTTSClient.#audioContext.currentTime - this.#pausedAt;
this.#startedAt = this.#audioElement.currentTime - this.#pausedAt;
}
async stop() {
@@ -207,21 +193,18 @@ export class EdgeTTSClient implements TTSClient {
this.#isPlaying = false;
this.#pausedAt = 0;
this.#startedAt = 0;
if (this.#sourceNode) {
try {
this.#sourceNode.stop();
if (this.#sourceNode?.onended) {
this.#sourceNode.onended(new Event('stopped'));
}
} catch (err) {
if (!(err instanceof Error) || err.name !== 'InvalidStateError') {
console.log('Error stopping source node:', err);
}
if (this.#audioElement) {
this.#audioElement.pause();
this.#audioElement.currentTime = 0;
if (this.#audioElement?.onended) {
this.#audioElement.onended(new Event('stopped'));
}
this.#sourceNode.disconnect();
this.#sourceNode = null;
if (this.#audioElement.src?.startsWith('blob:')) {
URL.revokeObjectURL(this.#audioElement.src);
}
this.#audioElement.src = '';
this.#audioElement = null;
}
this.#audioBuffer = null;
}
async setRate(rate: number) {
+17 -5
View File
@@ -1,7 +1,8 @@
import { Book } from '@/types/book';
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { RemoteFile } from '@/utils/file';
import { isPWA } from './environment';
import { BaseAppService } from './appService';
@@ -53,6 +54,14 @@ const indexedDBFileSystem: FileSystem = {
return path;
}
},
async openFile(path: string, base: BaseDir, filename?: string) {
if (isValidURL(path)) {
return await new RemoteFile(path, filename).open();
} else {
const content = await this.readFile(path, base, 'binary');
return new File([content], filename || path);
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp } = resolvePath(dstPath, base);
const db = await openIndexedDB();
@@ -174,6 +183,9 @@ const indexedDBFileSystem: FileSystem = {
request.onerror = () => reject(request.error);
});
},
getPrefix() {
return null;
},
};
export class WebAppService extends BaseAppService {
@@ -200,6 +212,10 @@ export class WebAppService extends BaseAppService {
return LOCAL_BOOKS_SUBDIR;
}
async getCacheDir(): Promise<string> {
return 'Cache';
}
async selectDirectory(): Promise<string> {
throw new Error('selectDirectory is not supported in browser');
}
@@ -208,10 +224,6 @@ export class WebAppService extends BaseAppService {
throw new Error('selectFiles is not supported in browser');
}
async showMessage(msg: string, kind: ToastType = 'info'): Promise<void> {
alert(`${kind.toUpperCase()}: ${msg}`);
}
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`);
};
+4 -1
View File
@@ -4,8 +4,10 @@ export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet';
export interface Book {
// if Book is a remote book we just lazy load the book content
// if Book is a remote book we just lazy load the book content via url
url?: string;
// if Book is a transient local book we can load the book content via filePath
filePath?: string;
hash: string;
format: BookFormat;
title: string;
@@ -103,6 +105,7 @@ export interface BookFont {
sansSerifFont: string;
monospaceFont: string;
defaultFont: string;
defaultCJKFont: string;
defaultFontSize: number;
minimumFontSize: number;
fontWeight: number;
+1
View File
@@ -22,6 +22,7 @@ export interface SystemSettings {
keepLogin: boolean;
autoUpload: boolean;
alwaysOnTop: boolean;
autoCheckUpdates: boolean;
screenWakeLock: boolean;
autoImportBooksOnOpen: boolean;
+4 -4
View File
@@ -5,19 +5,20 @@ import { ProgressHandler } from '@/utils/transfer';
export type AppPlatform = 'web' | 'tauri';
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None';
export type ToastType = 'info' | 'warning' | 'error';
export interface FileSystem {
getURL(path: string): string;
getBlobURL(path: string, base: BaseDir): Promise<string>;
openFile(path: string, base: BaseDir, filename?: string): Promise<File>;
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer): Promise<void>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File): Promise<void>;
removeFile(path: string, base: BaseDir): Promise<void>;
readDir(path: string, base: BaseDir): Promise<{ path: string; isDir: boolean }[]>;
createDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
removeDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
exists(path: string, base: BaseDir): Promise<boolean>;
getPrefix(base: BaseDir): string | null;
}
export interface AppService {
@@ -37,9 +38,8 @@ export interface AppService {
isAndroidApp: boolean;
isIOSApp: boolean;
selectDirectory(): Promise<string>;
selectFiles(name: string, extensions: string[]): Promise<string[]>;
showMessage(msg: string, kind?: ToastType, title?: string, okLabel?: string): Promise<void>;
loadSettings(): Promise<SystemSettings>;
saveSettings(settings: SystemSettings): Promise<void>;
importBook(
+2 -1
View File
@@ -19,7 +19,8 @@ export const getStoragePlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
const usage = data['storage_usage_bytes'] || 0;
const quota = DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_STORAGE_FIXED_QUOTA'] || '0');
const quota = fixedQuota || DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
return {
plan,
+10 -1
View File
@@ -1,6 +1,6 @@
import { EXTS } from '@/libs/document';
import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
import { getUserLang, makeSafeFilename } from './misc';
import { getUserLang, isContentURI, isValidURL, makeSafeFilename } from './misc';
import { getStorageType } from './object';
export const getDir = (book: Book) => {
@@ -31,6 +31,15 @@ export const getConfigFilename = (book: Book) => {
export const isBookFile = (filename: string) => {
return Object.values(EXTS).includes(filename.split('.').pop()!);
};
export const getFilename = (fileOrUri: string) => {
if (isValidURL(fileOrUri) || isContentURI(fileOrUri)) {
fileOrUri = decodeURI(fileOrUri);
}
const normalizedPath = fileOrUri.replace(/\\/g, '/');
const parts = normalizedPath.split('/');
const lastPart = parts.pop()!;
return lastPart.split('?')[0]!;
};
export const getBaseFilename = (filename: string) => {
const normalizedPath = filename.replace(/\\/g, '/');
const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
+29
View File
@@ -0,0 +1,29 @@
import { invoke } from '@tauri-apps/api/core';
export interface CopyURIRequest {
uri: string;
dst: string;
}
export interface CopyURIResponse {
success: boolean;
error?: string;
}
export interface UseBackgroundAudioRequest {
enabled: boolean;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
});
return result;
}
export async function invokeUseBackgroundAudio(request: UseBackgroundAudioRequest): Promise<void> {
await invoke('plugin:native-bridge|use_background_audio', {
payload: request,
});
}
+199 -6
View File
@@ -1,6 +1,7 @@
import { FileHandle, open, BaseDirectory, SeekMode } from '@tauri-apps/plugin-fs';
import { getOSPlatform } from './misc';
class RemoteBlobSlice extends Blob {
class DeferredBlob extends Blob {
#dataPromise: Promise<ArrayBuffer>;
#type: string;
@@ -49,7 +50,192 @@ class RemoteBlobSlice extends Blob {
}
}
export class RemoteFile extends File {
export interface ClosableFile extends File {
open(): Promise<this>;
close(): Promise<void>;
}
export class NativeFile extends File implements ClosableFile {
#handle: FileHandle | null = null;
#fp: string;
#name: string;
#baseDir: BaseDirectory | null;
#lastModified: number = 0;
#size: number = -1;
#type: string = '';
static MAX_CACHE_CHUNK_SIZE = 1024 * 1024;
static MAX_CACHE_ITEMS_SIZE = 20;
#cache: Map<number, ArrayBuffer> = new Map();
#order: number[] = [];
constructor(fp: string, name?: string, baseDir: BaseDirectory | null = null, type = '') {
super([], name || fp, { type });
this.#fp = fp;
this.#baseDir = baseDir;
this.#name = name || fp;
}
async open() {
this.#handle = await open(this.#fp, this.#baseDir ? { baseDir: this.#baseDir } : undefined);
const stats = await this.#handle.stat();
this.#size = stats.size;
this.#lastModified = stats.mtime ? stats.mtime.getTime() : Date.now();
return this;
}
async close() {
if (this.#handle) {
await this.#handle.close();
this.#handle = null;
}
this.#cache.clear();
this.#order = [];
}
override get name() {
return this.#name;
}
override get type() {
return this.#type;
}
override get size() {
return this.#size;
}
override get lastModified() {
return this.#lastModified;
}
async stat() {
return this.#handle?.stat();
}
async seek(offset: number, whence: SeekMode): Promise<number> {
if (!this.#handle) {
throw new Error('File handle is not open');
}
return this.#handle.seek(offset, whence);
}
// exclusive reading of the end: [start, end)
async readData(start: number, end: number): Promise<ArrayBuffer> {
if (!this.#handle) {
throw new Error('File handle is not open');
}
start = Math.max(0, start);
end = Math.max(start, Math.min(this.size, end));
const size = end - start;
if (size > NativeFile.MAX_CACHE_CHUNK_SIZE) {
await this.#handle.seek(start, SeekMode.Start);
const buffer = new Uint8Array(size);
await this.#handle.read(buffer);
return buffer.buffer;
}
const cachedChunkStart = Array.from(this.#cache.keys()).find((chunkStart) => {
const buffer = this.#cache.get(chunkStart)!;
return start >= chunkStart && end <= chunkStart + buffer.byteLength;
});
if (cachedChunkStart !== undefined) {
this.#updateAccessOrder(cachedChunkStart);
const buffer = this.#cache.get(cachedChunkStart)!;
const offset = start - cachedChunkStart;
return buffer.slice(offset, offset + size);
}
const chunkStart = Math.max(0, start - 1024);
const chunkEnd = Math.min(this.size, chunkStart + NativeFile.MAX_CACHE_CHUNK_SIZE);
const chunkSize = chunkEnd - chunkStart;
await this.#handle.seek(chunkStart, SeekMode.Start);
const buffer = new Uint8Array(chunkSize);
await this.#handle.read(buffer);
this.#cache.set(chunkStart, buffer.buffer);
this.#updateAccessOrder(chunkStart);
this.#ensureCacheSize();
const offset = start - chunkStart;
return buffer.buffer.slice(offset, offset + size);
}
#updateAccessOrder(chunkStart: number) {
const index = this.#order.indexOf(chunkStart);
if (index > -1) {
this.#order.splice(index, 1);
}
this.#order.unshift(chunkStart);
}
#ensureCacheSize() {
while (this.#cache.size > NativeFile.MAX_CACHE_ITEMS_SIZE) {
const oldestKey = this.#order.pop();
if (oldestKey !== undefined) {
this.#cache.delete(oldestKey);
}
}
}
override slice(start = 0, end = this.size, contentType = this.type): Blob {
// console.log(`Slicing: ${start}-${end}, size: ${end - start}`);
const dataPromise = this.readData(start, end);
return new DeferredBlob(dataPromise, contentType);
}
override stream(): ReadableStream<Uint8Array> {
const CHUNK_SIZE = 1024 * 1024;
let offset = 0;
return new ReadableStream<Uint8Array>({
pull: async (controller) => {
if (!this.#handle) {
controller.error(new Error('File handle is not open'));
return;
}
if (offset >= this.size) {
controller.close();
return;
}
const end = Math.min(offset + CHUNK_SIZE, this.size);
const buffer = new Uint8Array(end - offset);
await this.#handle.seek(offset, SeekMode.Start);
const bytesRead = await this.#handle.read(buffer);
if (bytesRead === null || bytesRead === 0) {
controller.close();
return;
}
controller.enqueue(buffer.subarray(0, bytesRead));
offset += bytesRead;
},
cancel: async () => {
await this.#handle?.close();
},
});
}
override async text() {
const blob = this.slice(0, this.size);
return blob.text();
}
override async arrayBuffer() {
const blob = this.slice(0, this.size);
return blob.arrayBuffer();
}
}
export class RemoteFile extends File implements ClosableFile {
url: string;
#name: string;
#lastModified: number;
@@ -61,10 +247,11 @@ export class RemoteFile extends File {
static MAX_CACHE_CHUNK_SIZE = 1024 * 128;
static MAX_CACHE_ITEMS_SIZE: number = 10;
constructor(url: string, name = 'remote-file', type = '', lastModified = Date.now()) {
super([], name, { type: type, lastModified });
constructor(url: string, name?: string, type = '', lastModified = Date.now()) {
const basename = url.split('/').pop() || 'remote-file';
super([], name || basename, { type, lastModified });
this.url = url;
this.#name = name;
this.#name = name || basename;
this.#type = type;
this.#lastModified = lastModified;
}
@@ -114,6 +301,11 @@ export class RemoteFile extends File {
}
}
async close(): Promise<void> {
this.#cache.clear();
this.#order = [];
}
async fetchRangePart(start: number, end: number) {
start = Math.max(0, start);
end = Math.min(this.size - 1, end);
@@ -125,6 +317,7 @@ export class RemoteFile extends File {
return response.arrayBuffer();
}
// inclusive reading of the end: [start, end]
async fetchRange(start: number, end: number): Promise<ArrayBuffer> {
const rangeSize = end - start + 1;
const MAX_RANGE_LEN = 1024 * 1000;
@@ -194,7 +387,7 @@ export class RemoteFile extends File {
// console.log(`Slicing: ${start}-${end}, size: ${end - start}`);
const dataPromise = this.fetchRange(start, end - 1);
return new RemoteBlobSlice(dataPromise, contentType);
return new DeferredBlob(dataPromise, contentType);
}
override async text() {
+10 -1
View File
@@ -32,7 +32,12 @@ export const makeSafeFilename = (filename: string, replacement = '_') => {
export const getUserLang = () => navigator?.language.split('-')[0] || 'en';
export const isCJKEnv = () => ['zh', 'ja', 'ko'].includes(getUserLang());
export const isCJKEnv = () => {
const isCJKLocale = ['zh', 'ja', 'ko'].includes(getUserLang());
const uiLanguage = localStorage.getItem('i18nextLng') || '';
const isCJKUI = ['zh', 'ja', 'ko'].some((lang) => uiLanguage.startsWith(lang));
return isCJKLocale || isCJKUI;
};
export const getUserLocale = (lang: string): string | undefined => {
const languages =
@@ -59,6 +64,10 @@ export const getOSPlatform = () => {
return '';
};
export const isContentURI = (uri: string) => {
return uri.startsWith('content://');
};
export const isValidURL = (url: string, allowedSchemes: string[] = ['http', 'https']) => {
try {
const { protocol } = new URL(url);
+71 -32
View File
@@ -8,7 +8,6 @@ import { ViewSettings } from '@/types/book';
import {
themes,
Palette,
ThemeMode,
CustomTheme,
generateLightPalette,
generateDarkPalette,
@@ -22,21 +21,30 @@ const getFontStyles = (
sansSerif: string,
monospace: string,
defaultFont: string,
defaultCJKFont: string,
fontSize: number,
minFontSize: number,
fontWeight: number,
overrideFont: boolean,
themeCode: ThemeCode,
) => {
const lastSerifFonts = ['Georgia', 'Times New Roman'];
const { primary } = themeCode;
const lastSerifFonts = ['LXGW WenKai GB Screen', 'Georgia', 'Times New Roman'];
const serifFonts = [
serif,
...SERIF_FONTS.filter((font) => font !== serif && !lastSerifFonts.includes(font)),
...SERIF_FONTS.filter(
(font) => font !== serif && font !== defaultCJKFont && !lastSerifFonts.includes(font),
),
...(defaultCJKFont !== serif && !lastSerifFonts.includes(defaultCJKFont)
? [defaultCJKFont]
: []),
...lastSerifFonts.filter((font) => SERIF_FONTS.includes(font)),
...FALLBACK_FONTS,
];
const sansSerifFonts = [
sansSerif,
...SANS_SERIF_FONTS.filter((font) => font !== sansSerif),
...SANS_SERIF_FONTS.filter((font) => font !== sansSerif && font !== defaultCJKFont),
...(defaultCJKFont !== sansSerif ? [defaultCJKFont] : []),
...FALLBACK_FONTS,
];
const monospaceFonts = [monospace, ...MONOSPACE_FONTS.filter((font) => font !== monospace)];
@@ -73,8 +81,18 @@ const getFontStyles = (
font-size: ${fontSize * 3}px;
}
body * {
font-family: revert ${overrideFont ? '!important' : ''};
font-family: inherit;
${overrideFont ? 'font-family: revert !important;' : ''}
${overrideFont ? 'font-size: revert !important;' : ''}
${overrideFont ? 'color: revert !important;' : ''}
}
a:any-link {
${overrideFont ? `color: ${primary};` : ''}
}
/* https://github.com/whatwg/html/issues/5426 */
@media (prefers-color-scheme: dark) {
a:link {
${overrideFont ? `color: lightblue;` : ''}
}
}
`;
return fontStyles;
@@ -141,29 +159,15 @@ const getLayoutStyles = (
zoomLevel: number,
writingMode: string,
vertical: boolean,
bg: string,
fg: string,
primary: string,
isDarkMode: boolean,
) => `
themeCode: ThemeCode,
) => {
const { bg, fg, primary, isDarkMode } = themeCode;
const layoutStyle = `
@namespace epub "http://www.idpf.org/2007/ops";
html {
color-scheme: ${isDarkMode ? 'dark' : 'light'};
color: ${fg};
}
a:any-link {
color: ${primary} ${isDarkMode ? '!important' : ''};
}
aside[epub|type~="footnote"] {
display: none;
}
/* https://github.com/whatwg/html/issues/5426 */
@media (prefers-color-scheme: dark) {
a:link {
color: lightblue;
}
}
html {
--theme-bg-color: ${bg};
--theme-fg-color: ${fg};
@@ -207,12 +211,11 @@ const getLayoutStyles = (
background-color: transparent !important;
}
p, li, blockquote, dd {
margin: ${vertical ? `0 ${paragraphMargin}em` : `${paragraphMargin}em 0`} ${overrideLayout ? '!important' : ''};
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
text-indent: ${textIndent}em ${overrideLayout ? '!important' : ''};
text-align: ${overrideLayout ? 'var(--default-text-align) !important' : 'inherit'};
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em;
text-align: ${overrideLayout ? 'var(--default-text-align)' : 'inherit'};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
hyphens: ${hyphenate ? 'auto' : 'manual'};
-webkit-hyphenate-limit-before: 3;
@@ -221,6 +224,12 @@ const getLayoutStyles = (
hanging-punctuation: allow-end last;
widows: 2;
}
p {
${vertical ? `margin-left: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${vertical ? `margin-right: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-top: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-bottom: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
}
li, p:has(> :is(img, video, font, h1, h2, h3, h4, h5, table)) {
text-indent: 0 !important;
}
@@ -240,6 +249,18 @@ const getLayoutStyles = (
display: none;
}
img.pi {
${vertical ? 'transform: rotate(90deg);' : ''}
${vertical ? 'transform-origin: center;' : ''}
${vertical ? 'height: 2em;' : ''}
${vertical ? `width: ${lineSpacing}em;` : ''}
${vertical ? `vertical-align: unset;` : ''}
}
aside[epub|type~="footnote"] {
display: none;
}
.duokan-footnote-content,
.duokan-footnote-item {
display: none;
@@ -254,6 +275,8 @@ const getLayoutStyles = (
border-color: unset;
}
`;
return layoutStyle;
};
export const getFootnoteStyles = () => `
.duokan-footnote-content,
@@ -295,7 +318,7 @@ export const getThemeCode = () => {
let customThemes: CustomTheme[] = [];
if (typeof window !== 'undefined') {
themeColor = localStorage.getItem('themeColor') || 'default';
themeMode = localStorage.getItem('themeMode') as ThemeMode;
themeMode = localStorage.getItem('themeMode') || 'auto';
customThemes = JSON.parse(localStorage.getItem('customThemes') || '[]');
systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
@@ -341,10 +364,7 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
viewSettings.zoomLevel! / 100.0,
viewSettings.writingMode!,
viewSettings.vertical!,
themeCode.bg,
themeCode.fg,
themeCode.primary,
themeCode.isDarkMode,
themeCode,
);
// scale the font size on-the-fly so that we can sync the same font size on different devices
const isMobile = ['ios', 'android'].includes(getOSPlatform());
@@ -354,10 +374,12 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
viewSettings.sansSerifFont!,
viewSettings.monospaceFont!,
viewSettings.defaultFont!,
viewSettings.defaultCJKFont!,
viewSettings.defaultFontSize! * fontScale,
viewSettings.minimumFontSize!,
viewSettings.fontWeight!,
viewSettings.overrideFont!,
themeCode,
);
const userStylesheet = viewSettings.userStylesheet!;
return `${layoutStyles}\n${fontStyles}\n${fontfacesCSS}\n${userStylesheet}`;
@@ -384,3 +406,20 @@ export const mountAdditionalFonts = (document: Document) => {
style.textContent = getAdditionalFontFaces();
document.head.appendChild(style);
};
export const transformStylesheet = (css: string) => {
// replace absolute font sizes with rem units
// replace hardcoded colors
return css
.replace(/font-size\s*:\s*xx-small/gi, 'font-size: 0.6rem')
.replace(/font-size\s*:\s*x-small/gi, 'font-size: 0.75rem')
.replace(/font-size\s*:\s*small/gi, 'font-size: 0.875rem')
.replace(/font-size\s*:\s*medium/gi, 'font-size: 1rem')
.replace(/font-size\s*:\s*large/gi, 'font-size: 1.2rem')
.replace(/font-size\s*:\s*x-large/gi, 'font-size: 1.5rem')
.replace(/font-size\s*:\s*xx-large/gi, 'font-size: 2rem')
.replace(/font-size\s*:\s*xxx-large/gi, 'font-size: 3rem')
.replace(/\scolor\s*:\s*#000000/gi, 'color: var(--theme-fg-color)')
.replace(/\scolor\s*:\s*#000/gi, 'color: var(--theme-fg-color)')
.replace(/\scolor\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)');
};
+3 -11
View File
@@ -1,3 +1,4 @@
import { getBaseFilename } from './book';
import { partialMD5 } from './md5';
interface Metadata {
@@ -38,12 +39,12 @@ export class TxtToEpubConverter {
public async convert(options: Txt2EpubOptions): Promise<ConversionResult> {
const { file: txtFile, author: providedAuthor, language: providedLanguage } = options;
const fileContent = await this.readFileAsArrayBuffer(txtFile);
const fileContent = await txtFile.arrayBuffer();
const detectedEncoding = this.detectEncoding(fileContent) || 'utf-8';
const decoder = new TextDecoder(detectedEncoding);
const txtContent = decoder.decode(fileContent).trim();
const bookTitle = this.extractBookTitle(txtFile.name);
const bookTitle = this.extractBookTitle(getBaseFilename(txtFile.name));
const fileName = `${bookTitle}.epub`;
const fileHeader = txtContent.slice(0, 1024);
@@ -358,15 +359,6 @@ export class TxtToEpubConverter {
return 'en';
}
private readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
private extractBookTitle(filename: string): string {
const match = filename.match(/《([^》]+)》/);
return match ? match[1]! : filename.split('.')[0]!;
+10
View File
@@ -38,6 +38,16 @@ export const tauriHandleToggleFullScreen = async () => {
await currentWindow.setFullscreen(newIsFullscreen);
};
export const tauriHandleSetAlwaysOnTop = async (isAlwaysOnTop: boolean) => {
const currentWindow = getCurrentWindow();
await currentWindow.setAlwaysOnTop(isAlwaysOnTop);
};
export const tauriGetAlwaysOnTop = async () => {
const currentWindow = getCurrentWindow();
return await currentWindow.isAlwaysOnTop();
};
export const tauriHandleOnWindowFocus = async (callback: () => void) => {
const currentWindow = getCurrentWindow();
return currentWindow.listen(TauriEvent.WINDOW_FOCUS, async () => {
+125 -114
View File
@@ -54,44 +54,44 @@ importers:
specifier: ^2.47.7
version: 2.47.7
'@tauri-apps/api':
specifier: 2.3.0
version: 2.3.0
specifier: 2.4.1
version: 2.4.1
'@tauri-apps/plugin-cli':
specifier: ^2.2.0
version: 2.2.0
'@tauri-apps/plugin-deep-link':
specifier: ^2.2.0
version: 2.2.0
specifier: ^2.2.1
version: 2.2.1
'@tauri-apps/plugin-dialog':
specifier: ^2.2.0
version: 2.2.0
specifier: ^2.2.1
version: 2.2.1
'@tauri-apps/plugin-fs':
specifier: ^2.2.0
version: 2.2.0
specifier: ^2.2.1
version: 2.2.1
'@tauri-apps/plugin-haptics':
specifier: ^2.2.3
version: 2.2.3
specifier: ^2.2.4
version: 2.2.4
'@tauri-apps/plugin-http':
specifier: ^2.3.0
version: 2.3.0
specifier: ^2.4.3
version: 2.4.3
'@tauri-apps/plugin-log':
specifier: ^2.2.3
version: 2.2.3
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-opener':
specifier: ^2.2.6
version: 2.2.6
'@tauri-apps/plugin-os':
specifier: ^2.2.0
version: 2.2.0
specifier: ^2.2.1
version: 2.2.1
'@tauri-apps/plugin-process':
specifier: ^2.2.0
version: 2.2.0
specifier: ^2.2.1
version: 2.2.1
'@tauri-apps/plugin-shell':
specifier: ~2.2.0
version: 2.2.0
specifier: ~2.2.1
version: 2.2.1
'@tauri-apps/plugin-updater':
specifier: ^2.5.1
version: 2.5.1
specifier: ^2.7.0
version: 2.7.0
'@zip.js/zip.js':
specifier: ^2.7.53
version: 2.7.53
@@ -160,8 +160,8 @@ importers:
specifier: ^0.5.12
version: 0.5.12(wrangler@4.4.0)
'@tauri-apps/cli':
specifier: 2.3.1
version: 2.3.1
specifier: 2.4.1
version: 2.4.1
'@types/cors':
specifier: ^2.8.17
version: 2.8.17
@@ -2477,113 +2477,120 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tauri-apps/api@2.3.0':
resolution: {integrity: sha512-33Z+0lX2wgZbx1SPFfqvzI6su63hCBkbzv+5NexeYjIx7WA9htdOKoRR7Dh3dJyltqS5/J8vQFyybiRoaL0hlA==}
'@tauri-apps/api@2.4.1':
resolution: {integrity: sha512-5sYwZCSJb6PBGbBL4kt7CnE5HHbBqwH+ovmOW6ZVju3nX4E3JX6tt2kRklFEH7xMOIwR0btRkZktuLhKvyEQYg==}
'@tauri-apps/cli-darwin-arm64@2.3.1':
resolution: {integrity: sha512-TOhSdsXYt+f+asRU+Dl+Wufglj/7+CX9h8RO4hl5k7D6lR4L8yTtdhpS7btaclOMmjYC4piNfJE70GoxhOoYWw==}
'@tauri-apps/cli-darwin-arm64@2.4.1':
resolution: {integrity: sha512-QME7s8XQwy3LWClTVlIlwXVSLKkeJ/z88pr917Mtn9spYOjnBfsgHAgGdmpWD3NfJxjg7CtLbhH49DxoFL+hLg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.3.1':
resolution: {integrity: sha512-LDwGg3AuBQ3aCeMAFaFwt0MSGOVFoXuXEe0z4QxQ7jZE5tdAOhKABaq4i569V5lShCgQZ6nLD/tmA5+GipvHnA==}
'@tauri-apps/cli-darwin-x64@2.4.1':
resolution: {integrity: sha512-/r89IcW6Ya1sEsFUEH7wLNruDTj7WmDWKGpPy7gATFtQr5JEY4heernqE82isjTUimnHZD8SCr0jA3NceI4ybw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.3.1':
resolution: {integrity: sha512-hu3HpbbtJBvHXw5i54QHwLxOUoXWqhf7CL2YYSPOrWEEQo10NKddulP61L5gfr5z+bSSaitfLwqgTidgnaNJCA==}
'@tauri-apps/cli-linux-arm-gnueabihf@2.4.1':
resolution: {integrity: sha512-9tDijkRB+CchAGjXxYdY9l/XzFpLp1yihUtGXJz9eh+3qIoRI043n3e+6xmU8ZURr7XPnu+R4sCmXs6HD+NCEQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.3.1':
resolution: {integrity: sha512-mEGgwkiGSKYXWHhGodo7zU9PCd2I/d6KkR+Wp1nzK+DxsCrEK6yJ5XxYLSQSDcKkM4dCxpVEPUiVMbDhmn08jg==}
'@tauri-apps/cli-linux-arm64-gnu@2.4.1':
resolution: {integrity: sha512-pnFGDEXBAzS4iDYAVxTRhAzNu3K2XPGflYyBc0czfHDBXopqRgMyj5Q9Wj7HAwv6cM8BqzXINxnb2ZJFGmbSgA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.3.1':
resolution: {integrity: sha512-tqQkafikGfnc7ISnGjSYkbpnzJKEyO8XSa0YOXTAL3J8R5Pss5ZIZY7G8kq1mwQSR/dPVR1ZLTVXgZGuysjP8w==}
'@tauri-apps/cli-linux-arm64-musl@2.4.1':
resolution: {integrity: sha512-Hp0zXgeZNKmT+eoJSCxSBUm2QndNuRxR55tmIeNm3vbyUMJN/49uW7nurZ5fBPsacN4Pzwlx1dIMK+Gnr9A69w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-x64-gnu@2.3.1':
resolution: {integrity: sha512-I3puDJ2wGEauXlXbzIHn2etz78TaWs1cpN6zre02maHr6ZR7nf7euTCOGPhhfoMG0opA5mT/eLuYpVw648/VAA==}
'@tauri-apps/cli-linux-riscv64-gnu@2.4.1':
resolution: {integrity: sha512-3T3bo2E4fdYRvzcXheWUeQOVB+LunEEi92iPRgOyuSVexVE4cmHYl+MPJF+EUV28Et0hIVTsHibmDO0/04lAFg==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.4.1':
resolution: {integrity: sha512-kLN0FdNONO+2i+OpU9+mm6oTGufRC00e197TtwjpC0N6K2K8130w7Q3FeODIM2CMyg0ov3tH+QWqKW7GNhHFzg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.3.1':
resolution: {integrity: sha512-rbWiCOBuQN7tPySkUyBs914uUikE3mEUOqV/IFospvKESw4UC3G1DL5+ybfXH7Orb8/in3JpJuVzYQjo+OSbBA==}
'@tauri-apps/cli-linux-x64-musl@2.4.1':
resolution: {integrity: sha512-a8exvA5Ub9eg66a6hsMQKJIkf63QAf9OdiuFKOsEnKZkNN2x0NLgfvEcqdw88VY0UMs9dBoZ1AGbWMeYnLrLwQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.3.1':
resolution: {integrity: sha512-PdTmUzSeTHjJuBpCV7L+V29fPhPtToU+NZU46slHKSA1aT38MiFDXBZ/6P5Zudrt9QPMfIubqnJKbK8Ivvv7Ww==}
'@tauri-apps/cli-win32-arm64-msvc@2.4.1':
resolution: {integrity: sha512-4JFrslsMCJQG1c573T9uqQSAbF3j/tMKkMWzsIssv8jvPiP++OG61A2/F+y9te9/Q/O95cKhDK63kaiO5xQaeg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.3.1':
resolution: {integrity: sha512-K/Xa97kspWT4UWj3t26lL2D3QsopTAxS7kWi5kObdqtAGn3qD52qBi24FH38TdvHYz4QlnLIb30TukviCgh4gw==}
'@tauri-apps/cli-win32-ia32-msvc@2.4.1':
resolution: {integrity: sha512-9eXfFORehYSCRwxg2KodfmX/mhr50CI7wyBYGbPLePCjr5z0jK/9IyW6r0tC+ZVjwpX48dkk7hKiUgI25jHjzA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.3.1':
resolution: {integrity: sha512-RgwzXbP8gAno3kQEsybMtgLp6D1Z1Nec2cftryYbPTJmoMJs6e4qgtxuTSbUz5SKnHe8rGgMiFSvEGoHvbG72Q==}
'@tauri-apps/cli-win32-x64-msvc@2.4.1':
resolution: {integrity: sha512-60a4Ov7Jrwqz2hzDltlS7301dhSAmM9dxo+IRBD3xz7yobKrgaHXYpWvnRomYItHcDd51VaKc9292H8/eE/gsw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.3.1':
resolution: {integrity: sha512-xewcw/ZsCqgilTy2h7+pp2Baxoy7zLR2wXOV7SZLzkb6SshHVbm1BFAjn8iFATURRW85KLzl6wSGJ2dQHjVHqw==}
'@tauri-apps/cli@2.4.1':
resolution: {integrity: sha512-9Ta81jx9+57FhtU/mPIckDcOBtPTUdKM75t4+aA0X84b8Sclb0jy1xA8NplmcRzp2fsfIHNngU2NiRxsW5+yOQ==}
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-cli@2.2.0':
resolution: {integrity: sha512-rvNhMog9rHr01Xk+trBFKJ0eZICIvPkm9GX6ogB89/0hROU/lf+a/sb4vC0wtSeR7zrJuCSxwxYuvHCZheaYFA==}
'@tauri-apps/plugin-deep-link@2.2.0':
resolution: {integrity: sha512-H6mkxr2KZ3XJcKL44tiq6cOjCw9DL8OgU1xjn3j26Qsn+H/roPFiyhR7CHuB8Ar+sQFj4YVlfmJwtBajK2FETQ==}
'@tauri-apps/plugin-deep-link@2.2.1':
resolution: {integrity: sha512-8skZ6qIH/kWaV8d6jj3aPvvkIOuqkVk0APRDey9n9N3Ueu3n4MIbuxpAKR2EdoAyQxnXxPTNVyjw2D35/vfGyg==}
'@tauri-apps/plugin-dialog@2.2.0':
resolution: {integrity: sha512-6bLkYK68zyK31418AK5fNccCdVuRnNpbxquCl8IqgFByOgWFivbiIlvb79wpSXi0O+8k8RCSsIpOquebusRVSg==}
'@tauri-apps/plugin-dialog@2.2.1':
resolution: {integrity: sha512-wZmCouo4PgTosh/UoejPw9DPs6RllS5Pp3fuOV2JobCu36mR5AXU2MzU9NZiVaFi/5Zfc8RN0IhcZHnksJ1o8A==}
'@tauri-apps/plugin-fs@2.2.0':
resolution: {integrity: sha512-+08mApuONKI8/sCNEZ6AR8vf5vI9DXD4YfrQ9NQmhRxYKMLVhRW164vdW5BSLmMpuevftpQ2FVoL9EFkfG9Z+g==}
'@tauri-apps/plugin-fs@2.2.1':
resolution: {integrity: sha512-KdGzvvA4Eg0Dhw55MwczFbjxLxsTx0FvwwC/0StXlr6IxwPUxh5ziZQoaugkBFs8t+wfebdQrjBEzd8NmmDXNw==}
'@tauri-apps/plugin-haptics@2.2.3':
resolution: {integrity: sha512-tHWAOR0TSOuWIdJ4Fh/4z+L8CvSfHdg5i7XfCqjErPu63PMf+2n856VIGcC6fjF29hf/vq+BiGApWt38n66Gvg==}
'@tauri-apps/plugin-haptics@2.2.4':
resolution: {integrity: sha512-h+YapjI0o6B1daj4gL7j8qKZUoxuZ09RWq+W8C62TXO5GHxc4YaywzGNGqq/3kJTYwv/3fw5o/pWO3AsN58RuQ==}
'@tauri-apps/plugin-http@2.3.0':
resolution: {integrity: sha512-pigTvz+zzAqbIhCzRiR1GE98Jw7A03j2V+Eiexr9thBI8VfMiwFQMcbgON51xlwnVaI72LdbYKNajU84im8tlg==}
'@tauri-apps/plugin-http@2.4.3':
resolution: {integrity: sha512-Us8X+FikzpaZRNr4kH4HLwyXascHbM42p6LxAqRTQnHPrrqp1usaH4vxWAZalPvTbHJ3gBEMJPHusFJgtjGJjA==}
'@tauri-apps/plugin-log@2.2.3':
resolution: {integrity: sha512-noDkZXU0kPG1bP3qoF6om+q5TgQ65LWpsV/TnfrHyg2lEn0vE+WJWTItiIh7OUA1/xxC+hdb9ODy2bM2e+jHEw==}
'@tauri-apps/plugin-log@2.3.1':
resolution: {integrity: sha512-nnKGHENWt7teqvUlIKxd6bp2wCUrrLvCvajN6CWbyrHBNKPi/pyKELzD511siEMDEdndbiZ/GEhiK0xBtZopRg==}
'@tauri-apps/plugin-opener@2.2.6':
resolution: {integrity: sha512-bSdkuP71ZQRepPOn8BOEdBKYJQvl6+jb160QtJX/i2H9BF6ZySY/kYljh76N2Ne5fJMQRge7rlKoStYQY5Jq1w==}
'@tauri-apps/plugin-os@2.2.0':
resolution: {integrity: sha512-HszbCdbisMlu5QhCNAN8YIWyz2v33abAWha6+uvV2CKX8P5VSct/y+kEe22JeyqrxCnWlQ3DRx7s49Byg7/0EA==}
'@tauri-apps/plugin-os@2.2.1':
resolution: {integrity: sha512-cNYpNri2CCc6BaNeB6G/mOtLvg8dFyFQyCUdf2y0K8PIAKGEWdEcu8DECkydU2B+oj4OJihDPD2de5K6cbVl9A==}
'@tauri-apps/plugin-process@2.2.0':
resolution: {integrity: sha512-uypN2Crmyop9z+KRJr3zl71OyVFgTuvHFjsJ0UxxQ/J5212jVa5w4nPEYjIewcn8bUEXacRebwE6F7owgrbhSw==}
'@tauri-apps/plugin-process@2.2.1':
resolution: {integrity: sha512-cF/k8J+YjjuowhNG1AboHNTlrGiOwgX5j6NzsX6WFf9FMzyZUchkCgZMxCdSE5NIgFX0vvOgLQhODFJgbMenLg==}
'@tauri-apps/plugin-shell@2.2.0':
resolution: {integrity: sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==}
'@tauri-apps/plugin-shell@2.2.1':
resolution: {integrity: sha512-G1GFYyWe/KlCsymuLiNImUgC8zGY0tI0Y3p8JgBCWduR5IEXlIJS+JuG1qtveitwYXlfJrsExt3enhv5l2/yhA==}
'@tauri-apps/plugin-updater@2.5.1':
resolution: {integrity: sha512-7fNJraKRbKkxguMY5lG2W20pBvAUkLu+cqnbu0UcK7DqeZgrAnNECcGBIDG6fJ6C+0fAp2V2dMIgznhffOBCcg==}
'@tauri-apps/plugin-updater@2.7.0':
resolution: {integrity: sha512-oBug5UCH2wOsoYk0LW5LEMAT51mszjg11s8eungRH26x/qOrEjLvnuJJoxVVr9nsWowJ6vnpXKS+lUMfFTlvHQ==}
'@tsconfig/node10@1.0.11':
resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==}
@@ -8093,7 +8100,7 @@ snapshots:
'@fabianlars/tauri-plugin-oauth@2.0.0':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@fastify/busboy@2.1.1': {}
@@ -9247,98 +9254,102 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tauri-apps/api@2.3.0': {}
'@tauri-apps/api@2.4.1': {}
'@tauri-apps/cli-darwin-arm64@2.3.1':
'@tauri-apps/cli-darwin-arm64@2.4.1':
optional: true
'@tauri-apps/cli-darwin-x64@2.3.1':
'@tauri-apps/cli-darwin-x64@2.4.1':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.3.1':
'@tauri-apps/cli-linux-arm-gnueabihf@2.4.1':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.3.1':
'@tauri-apps/cli-linux-arm64-gnu@2.4.1':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.3.1':
'@tauri-apps/cli-linux-arm64-musl@2.4.1':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.3.1':
'@tauri-apps/cli-linux-riscv64-gnu@2.4.1':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.3.1':
'@tauri-apps/cli-linux-x64-gnu@2.4.1':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.3.1':
'@tauri-apps/cli-linux-x64-musl@2.4.1':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.3.1':
'@tauri-apps/cli-win32-arm64-msvc@2.4.1':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.3.1':
'@tauri-apps/cli-win32-ia32-msvc@2.4.1':
optional: true
'@tauri-apps/cli@2.3.1':
'@tauri-apps/cli-win32-x64-msvc@2.4.1':
optional: true
'@tauri-apps/cli@2.4.1':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.3.1
'@tauri-apps/cli-darwin-x64': 2.3.1
'@tauri-apps/cli-linux-arm-gnueabihf': 2.3.1
'@tauri-apps/cli-linux-arm64-gnu': 2.3.1
'@tauri-apps/cli-linux-arm64-musl': 2.3.1
'@tauri-apps/cli-linux-x64-gnu': 2.3.1
'@tauri-apps/cli-linux-x64-musl': 2.3.1
'@tauri-apps/cli-win32-arm64-msvc': 2.3.1
'@tauri-apps/cli-win32-ia32-msvc': 2.3.1
'@tauri-apps/cli-win32-x64-msvc': 2.3.1
'@tauri-apps/cli-darwin-arm64': 2.4.1
'@tauri-apps/cli-darwin-x64': 2.4.1
'@tauri-apps/cli-linux-arm-gnueabihf': 2.4.1
'@tauri-apps/cli-linux-arm64-gnu': 2.4.1
'@tauri-apps/cli-linux-arm64-musl': 2.4.1
'@tauri-apps/cli-linux-riscv64-gnu': 2.4.1
'@tauri-apps/cli-linux-x64-gnu': 2.4.1
'@tauri-apps/cli-linux-x64-musl': 2.4.1
'@tauri-apps/cli-win32-arm64-msvc': 2.4.1
'@tauri-apps/cli-win32-ia32-msvc': 2.4.1
'@tauri-apps/cli-win32-x64-msvc': 2.4.1
'@tauri-apps/plugin-cli@2.2.0':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-deep-link@2.2.0':
'@tauri-apps/plugin-deep-link@2.2.1':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-dialog@2.2.0':
'@tauri-apps/plugin-dialog@2.2.1':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-fs@2.2.0':
'@tauri-apps/plugin-fs@2.2.1':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-haptics@2.2.3':
'@tauri-apps/plugin-haptics@2.2.4':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-http@2.3.0':
'@tauri-apps/plugin-http@2.4.3':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-log@2.2.3':
'@tauri-apps/plugin-log@2.3.1':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-opener@2.2.6':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-os@2.2.0':
'@tauri-apps/plugin-os@2.2.1':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-process@2.2.0':
'@tauri-apps/plugin-process@2.2.1':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-shell@2.2.0':
'@tauri-apps/plugin-shell@2.2.1':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tauri-apps/plugin-updater@2.5.1':
'@tauri-apps/plugin-updater@2.7.0':
dependencies:
'@tauri-apps/api': 2.3.0
'@tauri-apps/api': 2.4.1
'@tsconfig/node10@1.0.11':
optional: true
@@ -11231,7 +11242,7 @@ snapshots:
is-weakset@2.0.3:
dependencies:
call-bind: 1.0.7
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
isarray@1.0.0: {}