forked from akai/readest
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4908c4536 | |||
| 37951c2ebf | |||
| 915f222e08 | |||
| 9446df1b9c | |||
| bfcf89b093 | |||
| b0cf087d78 | |||
| 5863586766 | |||
| 99182a1f9e | |||
| 2599f40dfb | |||
| aa66ba54c5 | |||
| 4f0ef01a17 | |||
| 267e1656db | |||
| dcecda2984 | |||
| ce88f0229a | |||
| 316c82ea52 | |||
| 25be60ce2a | |||
| 4703eec045 | |||
| ab22a83332 | |||
| a3d6ebe44d | |||
| f8804b5d3c | |||
| ee3785ad51 | |||
| 5ba40cd2d6 | |||
| 6131180a31 | |||
| 7ccf3d0632 | |||
| 39fb7f759e | |||
| d89834a53d | |||
| 3c4dd2a143 | |||
| 847efb7082 | |||
| 01db8f90fa | |||
| e70c46ff02 | |||
| 7cb1998ebb | |||
| f5fd37f40d | |||
| 2daee62b80 | |||
| ffd179bb06 | |||
| 4acc0d8e12 |
@@ -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
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -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" }
|
||||
@@ -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 Won’t 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 doesn’t 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 you’ve 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "清除搜索"
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+18
@@ -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 ->
|
||||
|
||||
+1
-1
@@ -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
@@ -3,7 +3,4 @@ package com.readest.native_bridge
|
||||
import android.util.Log
|
||||
|
||||
class NativeBridge {
|
||||
fun auth_with_safari(value: String): String {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
+53
-4
@@ -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();
|
||||
}
|
||||
|
||||
+26
@@ -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)!
|
||||
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+83
@@ -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
-1
@@ -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"]
|
||||
|
||||
+43
-4
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -69,7 +69,12 @@ export const Toast = () => {
|
||||
messageClass.current,
|
||||
)}
|
||||
>
|
||||
{toastMessage}
|
||||
{toastMessage.split('\n').map((line, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{line || <> </>}
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SystemSettings {
|
||||
|
||||
keepLogin: boolean;
|
||||
autoUpload: boolean;
|
||||
alwaysOnTop: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
screenWakeLock: boolean;
|
||||
autoImportBooksOnOpen: boolean;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('.') || '';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)');
|
||||
};
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 605246a375...b82b872712
+1
-1
Submodule packages/tauri updated: f235ec0113...b154826881
Generated
+125
-114
@@ -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: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user