Compare commits

...

40 Commits

Author SHA1 Message Date
Huang Xin f9adf789d7 release: version 0.9.38 (#986) 2025-04-28 18:24:29 +02:00
Huang Xin efb8f70d01 fix: inherit book fonts for popup footnotes (#985) 2025-04-28 18:16:56 +02:00
Huang Xin e235cb98d2 fix: intercept the Back key to dismiss popup dialogs on Android, closes #636 (#983) 2025-04-28 16:57:15 +02:00
Huang Xin 4275508ccd feat: add volume keys for page turning, closes #471 (#982) 2025-04-28 12:36:24 +02:00
Huang Xin a424ae8b15 feat: retrieve system fonts on iOS and Android and show font weight variants, closes #949 and closes #557 (#976) 2025-04-26 17:37:04 +02:00
Huang Xin 9303ec8c5f feat: add primary color input in theme editor, closes #963 (#970) 2025-04-26 09:58:20 +02:00
Huang Xin 98faed320a feat: add more English voices for all en locales, closes #966 (#969) 2025-04-26 09:00:09 +02:00
Huang Xin 5bbdc70420 fix: preserve selection anchor when spanning paginated content, closes #873 (#968) 2025-04-26 07:45:51 +02:00
Huang Xin de21182240 fix: don't panic on plugin log initialization error, closes #953 (#961) 2025-04-25 14:42:57 +02:00
Huang Xin 4ae35992a1 compat: auto hide navigation bar also for Android version below 11 (#960) 2025-04-25 10:56:36 +02:00
Huang Xin b91d7b7a61 release: version 0.9.37 (#957) 2025-04-24 21:14:57 +02:00
Huang Xin 7c0d9bf375 fix: popup footnotes for anchors without epub namespace, closes #948 (#956) 2025-04-24 21:03:05 +02:00
Huang Xin ea8ab91ed3 feat: add list view for the bookshelf, closes #542 (#955) 2025-04-24 19:53:09 +02:00
Huang Xin efd415ece2 fix: dismiss status bar when resumed from background on Android (#952) 2025-04-24 11:12:10 +02:00
Huang Xin ec86d68b80 fix: check xdg-mime before registering deeplink in Linux, closes #475 (#951) 2025-04-24 16:19:14 +08:00
Huang Xin 172ab382bc fix: query status bar height on Android, closes #943 (#947) 2025-04-23 14:01:37 +02:00
Huang Xin ad551a04a1 feat: add description from metadata in book details, closes #913 (#946) 2025-04-22 19:01:43 +02:00
Huang Xin 4dc943d23e fix: tts now fallbacks to metadata language of the book, closes #916 (#945) 2025-04-22 18:12:14 +02:00
Huang Xin ee93885c04 fix: sync custom themes from global settings to localstorage, closes #917 (#944) 2025-04-22 14:52:46 +02:00
Huang Xin 14c032aaff feat: add global keyboard shortcut to toggle fullscreen with F11, closes #933 (#942) 2025-04-22 14:13:52 +02:00
Huang Xin 2f619ca745 fix: open book even the library is empty, closes #937 (#941) 2025-04-22 13:56:07 +02:00
Huang Xin 8979827cf5 fix: compat language code for api versions (#940) 2025-04-22 12:38:07 +02:00
Huang Xin ae16eeffa7 fix: prevent scrollToAnchor on focus outside content container, closes #906 (#928) 2025-04-21 14:57:01 +02:00
Huang Xin 8e96ef3e5d fix: hide navigation bar on reader page on Android 11, closes #921 (#927) 2025-04-21 14:49:16 +02:00
Huang Xin 38e24da968 fix: support v1 and v2 of translate API (#919) 2025-04-20 12:30:25 +08:00
Huang Xin 7371efe1d1 release: version 0.9.36 (#915) 2025-04-19 18:07:45 +02:00
Huang Xin 3cb37bca5d feat: add GuanKiapTsingKhai-T in the CJK fonts list, closes #884 (#914) 2025-04-19 18:00:35 +02:00
Huang Xin b9fa204eda fix: show system navigation bar by swiping up from bottom on Android, closes #592 (#912) 2025-04-19 17:02:17 +02:00
Huang Xin cf66665096 feat: immersive UI in reader page on iOS and Android, closes #886 and closes #864 (#911) 2025-04-19 16:43:39 +02:00
Huang Xin 759779a98d fix: links in about window is now clickable in apps (#902) 2025-04-17 11:17:07 +02:00
Huang Xin 68409db8a0 compat: more accessible iframes to support LingKuma and Immersive Translate extension, closes #889 (#901) 2025-04-17 10:36:04 +02:00
Huang Xin 46f0e8e7f6 feat: open files with readest from file manager on iOS (#898) 2025-04-16 17:36:11 +02:00
davimed 6260168caa Fix filename with quotes (#897)
* Escape double quotes in filenames

* Format code
2025-04-16 17:34:15 +02:00
Huang Xin a8430a7be1 feat: open files with readest from file manager on Android, closes #865 (#895) 2025-04-16 07:04:30 +02:00
Huang Xin 7bfaa38746 feat: add download/upload buttons in book detail dialogs (#891) 2025-04-15 06:19:29 +02:00
Huang Xin c5b2feda48 release: version 0.9.35 (#888) 2025-04-14 17:50:39 +02:00
Huang Xin 59f875f590 feat: add books sorting by title and author in the library page, closes #540 (#887) 2025-04-14 17:38:41 +02:00
Huang Xin 8efad90932 feat: in-app updater for Android (#885) 2025-04-14 14:21:58 +02:00
Huang Xin 5c23642ac0 chore: generate Android package signature and download url in update file 2025-04-13 23:59:56 +08:00
Huang Xin ee6500cecb fix: books without cover images now can be synced across devices (#878) 2025-04-13 10:47:50 +02:00
122 changed files with 3681 additions and 892 deletions
+34 -1
View File
@@ -130,7 +130,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('Cargo.lock') }}
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
@@ -152,6 +152,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.11902837
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
cd apps/readest-app/
rm -rf src-tauri/gen/android
@@ -179,6 +181,37 @@ jobs:
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
echo "Uploading $arm64_apk to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
echo "Uploading signatures to GitHub release"
pnpm tauri signer sign $universial_apk
pnpm tauri signer sign $arm64_apk
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
- name: download and update latest.json for Android release
if: matrix.config.release == 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd apps/readest-app/
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
version=${{ needs.get-release.outputs.release_version }}
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
arm64_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_arm64.apk"
universial_sig=$(cat Readest_${version}_universal.apk.sig)
arm64_sig=$(cat Readest_${version}_arm64.apk.sig)
jq --arg url "$universial_apk_url" \
--arg sig "$universial_sig" \
'.platforms["android-universal"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
jq --arg url "$arm64_apk_url" \
--arg sig "$arm64_sig" \
'.platforms["android-arm64"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
- uses: tauri-apps/tauri-action@v0
if: matrix.config.release != 'android'
Generated
+1 -1
View File
@@ -14,7 +14,6 @@ version = "0.2.2"
dependencies = [
"block",
"cocoa",
"font-enumeration",
"futures-util",
"log",
"objc",
@@ -5438,6 +5437,7 @@ dependencies = [
name = "tauri-plugin-native-bridge"
version = "0.1.0"
dependencies = [
"font-enumeration",
"schemars",
"serde",
"tauri",
+1 -1
View File
@@ -54,6 +54,7 @@
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
## Planned Features
@@ -62,7 +63,6 @@
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
+1 -1
View File
@@ -32,7 +32,7 @@ yarn-error.log*
/private_keys
# plists
*.plist
Entitlements*.plist
# local confs
tauri.*.conf.json
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.33",
"version": "0.9.38",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -27,7 +27,6 @@
"Delete": "حذف",
"Delete Highlight": "حذف التمييز",
"Dictionary": "القاموس",
"Disable Click-to-Flip": "تعطيل التقليب بالنقر",
"Download Readest": "تحميل ريديست",
"Edit": "تحرير",
"Enter your custom CSS here...": "أدخل الـ CSS المخصص هنا...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "تبديل منطقة التقليب بالنقر",
"No chapters detected.": "لم يتم التعرف على فصول.",
"Failed to parse the EPUB file.": "فشل في تحليل ملف EPUB.",
"This book format is not supported.": "هذا تنسيق الكتاب غير مدعوم.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "جارٍ تنزيل {{downloaded}} من {{contentLength}}",
"Download finished": "اكتمل التنزيل",
"DOWNLOAD & INSTALL": "تنزيل وتثبيت",
"Changelog:": "سجل التغييرات:"
"Changelog:": "سجل التغييرات:",
"Software Update": "تحديث البرنامج",
"Title": "العنوان",
"Date Read": "تاريخ القراءة",
"Date Added": "تاريخ الإضافة",
"Format": "التنسيق",
"Ascending": "تصاعدي",
"Descending": "تنازلي",
"Sort by...": "ترتيب حسب...",
"Added:": "تاريخ الإضافة:",
"Format:": "التنسيق:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "الوصف:",
"No description available": "لا يوجد وصف متاح",
"List": "قائمة",
"Grid": "شبكة",
"(from 'As You Like It', Act II)": "(من 'كما تحب'، الفصل الثاني)",
"Link Color": "لون الرابط",
"Volume Keys for Page Flip": "مفاتيح الصوت لتقليب الصفحات",
"Clicks for Page Flip": "نقرات لتقليب الصفحات",
"Swap Clicks Area": "تبديل منطقة النقرات"
}
@@ -27,7 +27,6 @@
"Delete": "Löschen",
"Delete Highlight": "Hervorhebung löschen",
"Dictionary": "Wörterbuch",
"Disable Click-to-Flip": "Klick zum Blättern deaktivieren",
"Download Readest": "Readest herunterladen",
"Edit": "Bearbeiten",
"Enter your custom CSS here...": "Geben Sie hier Ihr benutzerdefiniertes CSS ein...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Automatischer Import beim Öffnen einer Datei",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Herunterladen {{downloaded}} von {{contentLength}}",
"Download finished": "Download abgeschlossen",
"DOWNLOAD & INSTALL": "HERUNTERLADEN & INSTALLIEREN",
"Changelog:": "Änderungsprotokoll:"
"Changelog:": "Änderungsprotokoll:",
"Software Update": "Software-Update",
"Title": "Titel",
"Date Read": "Datum gelesen",
"Date Added": "Datum hinzugefügt",
"Format": "Format",
"Ascending": "Aufsteigend",
"Descending": "Absteigend",
"Sort by...": "Sortieren nach...",
"Added:": "Hinzugefügt:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Beschreibung:",
"No description available": "Keine Beschreibung verfügbar",
"List": "Liste",
"Grid": "Raster",
"(from 'As You Like It', Act II)": "(aus 'Wie es euch gefällt', Akt II)",
"Link Color": "Linkfarbe",
"Volume Keys for Page Flip": "Volumen-Tasten für Seitenwechsel",
"Clicks for Page Flip": "Tippen zum Blättern",
"Swap Clicks Area": "Tippbereiche tauschen"
}
@@ -27,7 +27,6 @@
"Delete": "Διαγραφή",
"Delete Highlight": "Διαγραφή επισήμανσης",
"Dictionary": "Λεξικό",
"Disable Click-to-Flip": "Απενεργοποίηση κλικ για γύρισμα σελίδας",
"Download Readest": "Λήψη Readest",
"Edit": "Επεξεργασία",
"Enter your custom CSS here...": "Εισάγετε το προσαρμοσμένο CSS εδώ...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Αυτόματη εισαγωγή κατά το άνοιγμα αρχείου",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας",
"No chapters detected.": "Δεν εντοπίστηκαν κεφάλαια.",
"Failed to parse the EPUB file.": "Αποτυχία ανάλυσης του αρχείου EPUB.",
"This book format is not supported.": "Αυτή η μορφή βιβλίου δεν υποστηρίζεται.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Κατεβάζω {{downloaded}} από {{contentLength}}",
"Download finished": "Η λήψη ολοκληρώθηκε",
"DOWNLOAD & INSTALL": "ΛΗΨΗ & ΕΓΚΑΤΑΣΤΑΣΗ",
"Changelog:": "Αλλαγές:"
"Changelog:": "Αλλαγές:",
"Software Update": "Ενημέρωση λογισμικού",
"Title": "Τίτλος",
"Date Read": "Ημερομηνία ανάγνωσης",
"Date Added": "Ημερομηνία προσθήκης",
"Format": "Μορφή",
"Ascending": "Αύξουσα",
"Descending": "Φθίνουσα",
"Sort by...": "Ταξινόμηση κατά...",
"Added:": "Προστέθηκε:",
"Format:": "Μορφή:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Περιγραφή:",
"No description available": "Δεν είναι διαθέσιμη καμία περιγραφή",
"List": "Λίστα",
"Grid": "Πλέγμα",
"(from 'As You Like It', Act II)": "('Όπως σας αρέσει', Πράξη II)",
"Link Color": "Χρώμα συνδέσμου",
"Volume Keys for Page Flip": "Πλήκτρα έντασης για ανατροπή σελίδας",
"Clicks for Page Flip": "Κλικ για ανατροπή σελίδας",
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ"
}
@@ -27,7 +27,6 @@
"Delete": "Eliminar",
"Delete Highlight": "Eliminar resaltado",
"Dictionary": "Diccionario",
"Disable Click-to-Flip": "Desactivar clic para voltear",
"Download Readest": "Descargar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Introduce tu CSS personalizado aquí...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Importación automática al abrir archivo",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Descargando {{downloaded}} de {{contentLength}}",
"Download finished": "Descarga finalizada",
"DOWNLOAD & INSTALL": "DESCARGAR E INSTALAR",
"Changelog:": "Notas de la versión:"
"Changelog:": "Notas de la versión:",
"Software Update": "Actualización de software",
"Title": "Título",
"Date Read": "Fecha de lectura",
"Date Added": "Fecha añadida",
"Format": "Formato",
"Ascending": "Ascendente",
"Descending": "Descendente",
"Sort by...": "Ordenar por...",
"Added:": "Agregado:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descripción:",
"No description available": "No hay descripción disponible",
"List": "Lista",
"Grid": "Cuadrícula",
"(from 'As You Like It', Act II)": "(de 'Como gustéis', Acto II)",
"Link Color": "Color del enlace",
"Volume Keys for Page Flip": "Botones de volumen para pasar página",
"Clicks for Page Flip": "Toques para pasar página",
"Swap Clicks Area": "Invertir zonas de toque"
}
@@ -27,7 +27,6 @@
"Delete": "Supprimer",
"Delete Highlight": "Supprimer le surlignage",
"Dictionary": "Dictionnaire",
"Disable Click-to-Flip": "Désactiver le clic pour tourner",
"Download Readest": "Télécharger Readest",
"Edit": "Modifier",
"Enter your custom CSS here...": "Saisissez votre CSS personnalisé ici...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Importation automatique à l'ouverture du fichier",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Téléchargement {{downloaded}} sur {{contentLength}}",
"Download finished": "Téléchargement terminé",
"DOWNLOAD & INSTALL": "TÉLÉCHARGER ET INSTALLER",
"Changelog:": "Journal des modifications :"
"Changelog:": "Journal des modifications :",
"Software Update": "Mise à jour logicielle",
"Title": "Titre",
"Date Read": "Date de lecture",
"Date Added": "Date ajoutée",
"Format": "Format",
"Ascending": "Ascendant",
"Descending": "Descendant",
"Sort by...": "Trier par...",
"Added:": "Ajouté :",
"Format:": "Format :",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Description :",
"No description available": "Aucune description disponible",
"List": "Liste",
"Grid": "Grille",
"(from 'As You Like It', Act II)": "(de 'Comme il vous plaira', Acte II)",
"Link Color": "Couleur du lien",
"Volume Keys for Page Flip": "Boutons volume pour tourner la page",
"Clicks for Page Flip": "Taps pour tourner la page",
"Swap Clicks Area": "Inverser zones de tap"
}
@@ -27,7 +27,6 @@
"Delete": "हटाएं",
"Delete Highlight": "हाइलाइट हटाएं",
"Dictionary": "शब्दकोश",
"Disable Click-to-Flip": "क्लिक-टू-फ्लिप अक्षम करें",
"Download Readest": "Readest डाउनलोड करें",
"Edit": "संपादित करें",
"Enter your custom CSS here...": "अपना कस्टम CSS यहां दर्ज करें...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "फ़ाइल खोलने पर स्वचालित आयात",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें",
"No chapters detected.": "कोई अध्याय नहीं मिला।",
"Failed to parse the EPUB file.": "EPUB फ़ाइल को पार्स करने में विफल।",
"This book format is not supported.": "यह पुस्तक प्रारूप समर्थित नहीं है।",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "डाउनलोड कर रहा है {{downloaded}} का {{contentLength}}",
"Download finished": "डाउनलोड पूरा हुआ",
"DOWNLOAD & INSTALL": "डाउनलोड और इंस्टॉल करें",
"Changelog:": "चेंजलॉग:"
"Changelog:": "चेंजलॉग:",
"Software Update": "सॉफ़्टवेयर अपडेट",
"Title": "शीर्षक",
"Date Read": "पढ़ने की तारीख",
"Date Added": "जोड़ा गया दिनांक",
"Format": "फॉर्मेट",
"Ascending": "आरोही",
"Descending": "अवरोही",
"Sort by...": "द्वारा क्रमबद्ध करें...",
"Added:": "जोड़ा गया:",
"Format:": "फॉर्मेट:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "विवरण:",
"No description available": "कोई विवरण उपलब्ध नहीं है",
"List": "सूची",
"Grid": "ग्रिड",
"(from 'As You Like It', Act II)": "('जैसा आप इसे पसंद करते हैं', अधिनियम II)",
"Link Color": "लिंक रंग",
"Volume Keys for Page Flip": "पृष्ठ फ़्लिप के लिए वॉल्यूम कुंजियाँ",
"Clicks for Page Flip": "पृष्ठ फ़्लिप के लिए क्लिक करें",
"Swap Clicks Area": "क्लिक क्षेत्र स्वैप करें"
}
@@ -27,7 +27,6 @@
"Delete": "Hapus",
"Delete Highlight": "Hapus Sorotan",
"Dictionary": "Kamus",
"Disable Click-to-Flip": "Nonaktifkan Klik untuk Membalik",
"Download Readest": "Unduh Readest",
"Edit": "Edit",
"Enter your custom CSS here...": "Masukkan CSS kustom Anda di sini...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Impor Otomatis saat File Dibuka",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Mengunduh {{downloaded}} dari {{contentLength}}",
"Download finished": "Unduhan selesai",
"DOWNLOAD & INSTALL": "UNDUH & INSTAL",
"Changelog:": "Catatan Perubahan:"
"Changelog:": "Catatan Perubahan:",
"Software Update": "Pembaruan Perangkat Lunak",
"Title": "Judul",
"Date Read": "Tanggal Dibaca",
"Date Added": "Tanggal Ditambahkan",
"Format": "Format",
"Ascending": "Naik",
"Descending": "Turun",
"Sort by...": "Urutkan berdasarkan...",
"Added:": "Ditambahkan:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Deskripsi:",
"No description available": "Tidak ada deskripsi yang tersedia",
"List": "Daftar",
"Grid": "Jala",
"(from 'As You Like It', Act II)": "(dari 'Seperti yang Anda Inginkan', Bab II)",
"Link Color": "Warna Tautan",
"Volume Keys for Page Flip": "Volume Keys untuk Pembalikan Halaman",
"Clicks for Page Flip": "Klik untuk Pembalikan Halaman",
"Swap Clicks Area": "Area Klik Tukar"
}
@@ -27,7 +27,6 @@
"Delete": "Elimina",
"Delete Highlight": "Elimina evidenziazione",
"Dictionary": "Dizionario",
"Disable Click-to-Flip": "Disattiva click per voltare pagina",
"Download Readest": "Scarica Readest",
"Edit": "Modifica",
"Enter your custom CSS here...": "Inserisci qui il tuo CSS personalizzato...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Importazione automatica all'apertura del file",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Scaricando {{downloaded}} di {{contentLength}}",
"Download finished": "Download completato",
"DOWNLOAD & INSTALL": "SCARICA E INSTALLA",
"Changelog:": "Note di rilascio:"
"Changelog:": "Note di rilascio:",
"Software Update": "Aggiornamento software",
"Title": "Titolo",
"Date Read": "Data di lettura",
"Date Added": "Data aggiunta",
"Format": "Formato",
"Ascending": "Crescente",
"Descending": "Decrescente",
"Sort by...": "Ordina per...",
"Added:": "Aggiunto:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descrizione:",
"No description available": "Nessuna descrizione disponibile",
"List": "Elenco",
"Grid": "Griglia",
"(from 'As You Like It', Act II)": "(da 'Come vi piace', Atto II)",
"Link Color": "Colore link",
"Volume Keys for Page Flip": "Tasti volume per girare pagina",
"Clicks for Page Flip": "Clic per girare pagina",
"Swap Clicks Area": "Inverti area dei clic"
}
@@ -27,7 +27,6 @@
"Delete": "削除",
"Delete Highlight": "ハイライトを削除",
"Dictionary": "辞書",
"Disable Click-to-Flip": "クリックめくりを無効化",
"Download Readest": "Readestをダウンロード",
"Edit": "編集",
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "ファイルを開くと自動インポート",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "クリックめくりエリアを入れ替え",
"No chapters detected.": "章が検出されません。",
"Failed to parse the EPUB file.": "EPUBファイルの解析に失敗しました。",
"This book format is not supported.": "この書籍形式はサポートされていません。",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "ダウンロード中 {{downloaded}} / {{contentLength}}",
"Download finished": "ダウンロード完了",
"DOWNLOAD & INSTALL": "更新",
"Changelog:": "変更ログ:"
"Changelog:": "変更ログ:",
"Software Update": "ソフトウェアの更新",
"Title": "タイトル",
"Date Read": "読書日",
"Date Added": "追加日",
"Format": "形式",
"Ascending": "昇順",
"Descending": "降順",
"Sort by...": "並べ替え...",
"Added:": "追加日:",
"Format:": "形式:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "説明:",
"No description available": "説明はありません",
"List": "リスト",
"Grid": "グリッド",
"(from 'As You Like It', Act II)": "(「お気に召すまま」第2幕)",
"Link Color": "リンクカラー",
"Volume Keys for Page Flip": "音量キーでページめくり",
"Clicks for Page Flip": "タップでページめくり",
"Swap Clicks Area": "タップ領域を入れ替え"
}
@@ -27,7 +27,6 @@
"Delete": "삭제",
"Delete Highlight": "하이라이트 삭제",
"Dictionary": "사전",
"Disable Click-to-Flip": "클릭 넘기기 비활성화",
"Download Readest": "Readest 다운로드",
"Edit": "편집",
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "파일 열 때 자동 가져오기",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "클릭-뒤집기 영역 교환",
"No chapters detected.": "장 발견되지 않음.",
"Failed to parse the EPUB file.": "EPUB 파일 구문 분석 실패.",
"This book format is not supported.": "이 책 형식은 지원되지 않습니다.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "다운로드 중 {{downloaded}} / {{contentLength}}",
"Download finished": "다운로드 완료",
"DOWNLOAD & INSTALL": "다운로드 및 설치",
"Changelog:": "변경 로그:"
"Changelog:": "변경 로그:",
"Software Update": "소프트웨어 업데이트",
"Title": "제목",
"Date Read": "읽은 날짜",
"Date Added": "추가된 날짜",
"Format": "형식",
"Ascending": "오름차순",
"Descending": "내림차순",
"Sort by...": "정렬 기준...",
"Added:": "추가됨:",
"Format:": "형식:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "설명:",
"No description available": "설명 없음",
"List": "목록",
"Grid": "격자",
"(from 'As You Like It', Act II)": "(당신이 좋아하는 대로, 2막)",
"Link Color": "링크 색상",
"Volume Keys for Page Flip": "페이지 넘김을 위한 볼륨 키",
"Clicks for Page Flip": "페이지 넘김을 위한 클릭",
"Swap Clicks Area": "클릭 영역 교환"
}
@@ -27,7 +27,6 @@
"Delete": "Usuń",
"Delete Highlight": "Usuń zaznaczenie",
"Dictionary": "Słownik",
"Disable Click-to-Flip": "Wyłącz przewracanie kliknięciem",
"Download Readest": "Pobierz Readest",
"Edit": "Edytuj",
"Enter your custom CSS here...": "Wprowadź własny CSS tutaj...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Automatyczne importowanie przy otwieraniu pliku",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Pobieranie {{downloaded}} z {{contentLength}}",
"Download finished": "Pobieranie zakończone",
"DOWNLOAD & INSTALL": "POBIERZ I ZAINSTALUJ",
"Changelog:": "Dziennik zmian:"
"Changelog:": "Dziennik zmian:",
"Software Update": "Aktualizacja oprogramowania",
"Title": "Tytuł",
"Date Read": "Data przeczytania",
"Date Added": "Data dodania",
"Format": "Format",
"Ascending": "Rosnąco",
"Descending": "Malejąco",
"Sort by...": "Sortuj według...",
"Added:": "Data dodania:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Opis:",
"No description available": "Brak opisu",
"List": "Lista",
"Grid": "Siatka",
"(from 'As You Like It', Act II)": "(z „Jak wam się podoba”, Akt II)",
"Link Color": "Kolor linku",
"Volume Keys for Page Flip": "Klucze głośności do przewracania stron",
"Clicks for Page Flip": "Kliknięcia do przewracania stron",
"Swap Clicks Area": "Zamień obszar kliknięcia"
}
@@ -27,7 +27,6 @@
"Delete": "Excluir",
"Delete Highlight": "Excluir Destaque",
"Dictionary": "Dicionário",
"Disable Click-to-Flip": "Desativar Clique para Virar",
"Download Readest": "Baixar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Insira seu CSS personalizado aqui...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Importação Automática ao Abrir Arquivo",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Baixando {{downloaded}} de {{contentLength}}",
"Download finished": "Download concluído",
"DOWNLOAD & INSTALL": "BAIXAR E INSTALAR",
"Changelog:": "Notas de versão:"
"Changelog:": "Notas de versão:",
"Software Update": "Atualização de Software",
"Title": "Título",
"Date Read": "Data de leitura",
"Date Added": "Data de adição",
"Format": "Formato",
"Ascending": "Crescente",
"Descending": "Decrescente",
"Sort by...": "Ordenar por...",
"Added:": "Adicionado:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descrição:",
"No description available": "Nenhuma descrição disponível",
"List": "Lista",
"Grid": "Grade",
"(from 'As You Like It', Act II)": "(de 'Como Gostais', Ato II)",
"Link Color": "Cor do Link",
"Volume Keys for Page Flip": "Teclas de Volume para Virar Página",
"Clicks for Page Flip": "Cliques para Virar Página",
"Swap Clicks Area": "Trocar Área de Cliques"
}
@@ -27,7 +27,6 @@
"Delete": "Удалить",
"Delete Highlight": "Удалить выделение",
"Dictionary": "Словарь",
"Disable Click-to-Flip": "Отключить переворот по клику",
"Download Readest": "Скачать Readest",
"Edit": "Редактировать",
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Автоматический импорт при открытии файла",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Переключить область клика для переворота",
"No chapters detected.": "Не обнаружено глав.",
"Failed to parse the EPUB file.": "Не удалось разобрать файл EPUB.",
"This book format is not supported.": "Этот формат книги не поддерживается.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Скачано {{downloaded}} из {{contentLength}}",
"Download finished": "Загрузка завершена",
"DOWNLOAD & INSTALL": "СКАЧАТЬ И УСТАНОВИТЬ",
"Changelog:": "Изменения:"
"Changelog:": "Изменения:",
"Software Update": "Обновление программного обеспечения",
"Title": "Название",
"Date Read": "Дата прочтения",
"Date Added": "Дата добавления",
"Format": "Формат",
"Ascending": "По возрастанию",
"Descending": "По убыванию",
"Sort by...": "Сортировать по...",
"Added:": "Добавлено:",
"Format:": "Формат:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Описание:",
"No description available": "Нет доступного описания",
"List": "Список",
"Grid": "Сетка",
"(from 'As You Like It', Act II)": "(из 'Как вам это понравится', Акт II)",
"Link Color": "Цвет ссылки",
"Volume Keys for Page Flip": "Клавиши громкости для перелистывания страниц",
"Clicks for Page Flip": "Клики для перелистывания страниц",
"Swap Clicks Area": "Переключить область кликов"
}
@@ -27,7 +27,6 @@
"Delete": "Sil",
"Delete Highlight": "Vurguyu Sil",
"Dictionary": "Sözlük",
"Disable Click-to-Flip": "Tıkla-Çevir'i Devre Dışı Bırak",
"Download Readest": "Readest'i İndir",
"Edit": "Düzenle",
"Enter your custom CSS here...": "Özel CSS'nizi buraya girin...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Dosya Açıldığında Otomatik İçe Aktar",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "İndiriliyor {{downloaded}}/{{contentLength}}",
"Download finished": "İndirme tamamlandı",
"DOWNLOAD & INSTALL": "İNDİR & YÜKLE",
"Changelog:": "Değişiklik Günlüğü:"
"Changelog:": "Değişiklik Günlüğü:",
"Software Update": "Yazılım Güncellemesi",
"Title": "Başlık",
"Date Read": "Okunma Tarihi",
"Date Added": "Eklenme Tarihi",
"Format": "Biçim",
"Ascending": "Artan",
"Descending": "Azalan",
"Sort by...": "Şuna göre sırala...",
"Added:": "Eklenme:",
"Format:": "Biçim:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Tanım:",
"No description available": "Tanım mevcut değil",
"List": "Liste",
"Grid": "Izgara",
"(from 'As You Like It', Act II)": "(As You Like It, 2. Perde)",
"Link Color": "Bağlantı Rengi",
"Volume Keys for Page Flip": "Sayfa Çevirme için Ses Seviyesi Tuşları",
"Clicks for Page Flip": "Sayfa Çevirme için Tıklamalar",
"Swap Clicks Area": "Tıklama Alanını Değiştir"
}
@@ -27,7 +27,6 @@
"Delete": "Видалити",
"Delete Highlight": "Видалити виділення",
"Dictionary": "Словник",
"Disable Click-to-Flip": "Вимкнути гортання кліком",
"Download Readest": "Завантажити Readest",
"Edit": "Редагувати",
"Enter your custom CSS here...": "Введіть ваш користувацький CSS тут...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Swap Click-to-Flip Area": "Поміняти місцями область кліку для перевороту",
"No chapters detected.": "Не виявлено жодного розділу.",
"Failed to parse the EPUB file.": "Не вдалося розібрати файл EPUB.",
"This book format is not supported.": "Цей формат книги не підтримується.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Завантаження {{downloaded}} з {{contentLength}}",
"Download finished": "Завантаження завершено",
"DOWNLOAD & INSTALL": "ЗАВАНТАЖИТИ І ВСТАНОВИТИ",
"Changelog:": "Журнал змін:"
"Changelog:": "Журнал змін:",
"Software Update": "Оновлення програмного забезпечення",
"Title": "Назва",
"Date Read": "Дата прочитання",
"Date Added": "Дата додавання",
"Format": "Формат",
"Ascending": "За зростанням",
"Descending": "За спаданням",
"Sort by...": "Сортувати за...",
"Added:": "Додано:",
"Format:": "Формат:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Опис:",
"No description available": "Опис недоступний",
"List": "Список",
"Grid": "Сітка",
"(from 'As You Like It', Act II)": "(з 'Як вам це подобається', Акт II)",
"Link Color": "Колір посилання",
"Volume Keys for Page Flip": "Клавіші гучності для перевороту сторінки",
"Clicks for Page Flip": "Клацання для перевороту сторінки",
"Swap Clicks Area": "Змінити область клацання"
}
@@ -27,7 +27,6 @@
"Delete": "Xóa",
"Delete Highlight": "Xóa văn bản được đánh dấu",
"Dictionary": "Từ điển",
"Disable Click-to-Flip": "Tắt tính năng nhấp để lật trang",
"Download Readest": "Tải Readest",
"Edit": "Chỉnh sửa",
"Enter your custom CSS here...": "Nhập CSS tùy chỉnh của bạn vào đây...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "Tự động nhập khi mở tệp",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"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ợ.",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "Đang tải {{downloaded}} trong {{contentLength}}",
"Download finished": "Tải xuống hoàn tất",
"DOWNLOAD & INSTALL": "TẢI XUỐNG & CÀI ĐẶT",
"Changelog:": "Thay đổi:"
"Changelog:": "Thay đổi:",
"Software Update": "Cập nhật phần mềm",
"Title": "Tiêu đề",
"Date Read": "Ngày đã đọc",
"Date Added": "Ngày thêm",
"Format": "Định dạng",
"Ascending": "Tăng dần",
"Descending": "Giảm dần",
"Sort by...": "Sắp xếp theo...",
"Added:": "Đã thêm:",
"Format:": "Định dạng:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Miêu tả:",
"No description available": "Không có mô tả nào",
"List": "Danh sách",
"Grid": "Lưới",
"(from 'As You Like It', Act II)": "(từ 'Như bạn thích', Hành động II)",
"Link Color": "Màu liên kết",
"Volume Keys for Page Flip": "Phím âm lượng để lật trang",
"Clicks for Page Flip": "Nhấp để lật trang",
"Swap Clicks Area": "Đổi khu vực nhấp"
}
@@ -27,7 +27,6 @@
"Delete": "删除",
"Delete Highlight": "删除划线",
"Dictionary": "词典",
"Disable Click-to-Flip": "禁用点击翻页",
"Download Readest": "下载 Readest",
"Edit": "编辑",
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "打开文件时自动导入",
"LXGW WenKai GB Screen": "霞鹜文楷",
"LXGW WenKai TC": "霞鶩文楷",
"Swap Click-to-Flip Area": "交换点击翻页区域",
"No chapters detected.": "未检测到章节。",
"Failed to parse the EPUB file.": "无法解析 EPUB 文件。",
"This book format is not supported.": "不支持此书籍格式。",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "正在下载 {{downloaded}} / {{contentLength}}",
"Download finished": "下载完成",
"DOWNLOAD & INSTALL": "下载并安装",
"Changelog:": "更新日志:"
"Changelog:": "更新日志:",
"Software Update": "软件更新",
"Title": "书名",
"Date Read": "阅读日期",
"Date Added": "添加日期",
"Format": "格式",
"Ascending": "升序",
"Descending": "降序",
"Sort by...": "排序方式...",
"Added:": "添加日期",
"Format:": "格式",
"GuanKiapTsingKhai-T": "原俠正楷-T",
"Description:": "简介",
"No description available": "暂无简介",
"List": "列表",
"Grid": "网格",
"(from 'As You Like It', Act II)": "出自《皆大欢喜》,第二幕",
"Link Color": "链接颜色",
"Volume Keys for Page Flip": "音量键翻页",
"Clicks for Page Flip": "点击翻页",
"Swap Clicks Area": "交换点击区域"
}
@@ -27,7 +27,6 @@
"Delete": "刪除",
"Delete Highlight": "刪除劃線",
"Dictionary": "詞典",
"Disable Click-to-Flip": "禁用點擊翻頁",
"Download Readest": "下載 Readest",
"Edit": "編輯",
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
@@ -259,7 +258,6 @@
"Auto Import on File Open": "打開文件時自動導入",
"LXGW WenKai GB Screen": "霞鶩文楷 SC",
"LXGW WenKai TC": "霞鶩文楷 TC",
"Swap Click-to-Flip Area": "交換點擊翻頁區域",
"No chapters detected.": "未檢測到章節。",
"Failed to parse the EPUB file.": "無法解析 EPUB 文件。",
"This book format is not supported.": "不支援此書籍格式。",
@@ -281,5 +279,25 @@
"Downloading {{downloaded}} of {{contentLength}}": "正在下載 {{downloaded}} / {{contentLength}}",
"Download finished": "下載完成",
"DOWNLOAD & INSTALL": "下載並安裝",
"Changelog:": "更新日誌:"
"Changelog:": "更新日誌:",
"Software Update": "軟體更新",
"Title": "書名",
"Date Read": "閱讀日期",
"Date Added": "添加日期",
"Format": "格式",
"Ascending": "升序",
"Descending": "降序",
"Sort by...": "排序方式...",
"Added:": "添加日期",
"Format:": "格式",
"GuanKiapTsingKhai-T": "原俠正楷-T",
"Description:": "簡介",
"No description available": "暫無簡介",
"List": "列表",
"Grid": "網格",
"(from 'As You Like It', Act II)": "(出自《皆大歡喜》,第二幕)",
"Link Color": "連結顏色",
"Volume Keys for Page Flip": "音量鍵翻頁",
"Clicks for Page Flip": "點擊翻頁",
"Swap Clicks Area": "交換點擊區域"
}
+42
View File
@@ -1,5 +1,47 @@
{
"releases": {
"0.9.38": {
"date": "2025-04-29",
"notes": [
"Page Turning with Volume Keys: You can now turn pages using your devices volume buttons",
"Expanded English Voices: More English voices are available across all en locales",
"Theme Editor: Added a primary color picker for easier customization",
"System Fonts: Retrieve system fonts on iOS and Android, with improved font weight and style detection",
"Popup Footnotes: Popup footnotes now inherit the book's font for consistent appearance",
"Better Text Selection: Preserves text selection anchors when spanning paginated content",
"Android Compatibility: Navigation bar auto-hides correctly on Android 10 and below",
"Popup Dialogs: Back key now properly dismisses popup dialogs on Android"
]
},
"0.9.37": {
"date": "2025-04-25",
"notes": [
"Full-Screen Toggle: You can now press F11 to quickly toggle full-screen mode",
"List View: A new list layout is available for your bookshelf",
"Book Details: Book descriptions from metadata are now shown in the details view",
"Enhanced Android behavior: better navigation bar handling and status bar dismissal when resuming",
"TTS now defaults to the books metadata language when needed",
"Synced custom themes reliably across sessions"
]
},
"0.9.36": {
"date": "2025-04-20",
"notes": [
"📥 New download and upload buttons in the book detail dialog for easier file management",
"📂 You can now open files directly in Readest from your file manager on Android and iOS",
"🛠️ Fixed an issue where files with quotation marks in the filename could not be opened",
"📖 Improved the immersive reading experience on the reader page for Android and iOS",
"🌐 Added GuanKiapTsingKhai-T to the list of available CJK fonts"
]
},
"0.9.35": {
"date": "2025-04-15",
"notes": [
"🛠️ Fixed an issue where books without cover images can not be synced across devices",
"📱 Added in-app update support for Android",
"📚 You can now sort books by title or author in your library"
]
},
"0.9.33": {
"date": "2025-04-13",
"notes": [
-1
View File
@@ -63,4 +63,3 @@ tauri-plugin-cli = "2"
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"
+201
View File
@@ -0,0 +1,201 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<false/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIHomeIndicatorAutoHidden</key>
<true/>
<key>UIRequiresFullScreen</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>EPUB Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>org.idpf.epub-container</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>PDF Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.pdf</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>FB2 Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.readest.fb2</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>CBZ Archive</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.readest.cbz</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>MOBI Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>org.mobipocket.mobi</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>AZW Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.amazon.azw</string>
<string>com.amazon.azw3</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Text File</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.idpf.epub-container</string>
<key>UTTypeDescription</key>
<string>EPUB Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>epub</string>
</array>
<key>public.mime-type</key>
<string>application/epub+zip</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.readest.fb2</string>
<key>UTTypeDescription</key>
<string>FB2 Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>fb2</string>
</array>
<key>public.mime-type</key>
<string>application/xml</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.readest.cbz</string>
<key>UTTypeDescription</key>
<string>CBZ Archive</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.archive</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>cbz</string>
</array>
<key>public.mime-type</key>
<string>application/x-cbz</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>org.mobipocket.mobi</string>
<key>UTTypeDescription</key>
<string>MOBI Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mobi</string>
</array>
<key>public.mime-type</key>
<string>application/x-mobipocket-ebook</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.amazon.azw</string>
<key>UTTypeDescription</key>
<string>AZW Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>azw</string>
<string>azw3</string>
</array>
<key>public.mime-type</key>
<string>application/vnd.amazon.ebook</string>
</dict>
</dict>
</array>
</dict>
</plist>
@@ -39,6 +39,17 @@
}
]
},
{
"identifier": "fs:allow-cache-read",
"allow": [
{
"path": "$CACHEDIR/**/*"
},
{
"path": "/private/var/mobile/Containers/Data/Application/**/*"
}
]
},
{
"identifier": "http:default",
"allow": [
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -19,12 +20,43 @@
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.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/epub+zip" />
<data android:mimeType="application/pdf" />
<data android:mimeType="application/fb2" />
<data android:mimeType="application/x-fb2" />
<data android:mimeType="application/vnd.amazon.mobi8-ebook" />
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/x-cbz" />
<data android:mimeType="application/x-mobipocket-ebook" />
<data android:mimeType="application/zip" />
<data android:mimeType="text/plain" />
<data android:pathPattern=".*\\.epub" />
<data android:pathPattern=".*\\.pdf" />
<data android:pathPattern=".*\\.fb2" />
<data android:pathPattern=".*\\.fb2.zip" />
<data android:pathPattern=".*\\.mobi" />
<data android:pathPattern=".*\\.azw" />
<data android:pathPattern=".*\\.cbz" />
<data android:pathPattern=".*\\.zip" />
<data android:pathPattern=".*\\.txt" />
</intent-filter>
<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" />
@@ -1,51 +1,81 @@
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
import android.view.KeyEvent
import android.webkit.WebView
import android.util.Log
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import com.readest.native_bridge.KeyDownInterceptor
class MainActivity : TauriActivity(), KeyDownInterceptor {
private lateinit var wv: WebView
private var interceptVolumeKeysEnabled = false
private var interceptBackKeyEnabled = false
override fun onWebViewCreate(webView: WebView) {
wv = webView
}
private val keyEventMap = mapOf(
KeyEvent.KEYCODE_BACK to "Back",
KeyEvent.KEYCODE_VOLUME_DOWN to "VolumeDown",
KeyEvent.KEYCODE_VOLUME_UP to "VolumeUp"
)
override fun interceptVolumeKeys(enabled: Boolean) {
Log.d("MainActivity", "Intercept volume keys: $enabled")
interceptVolumeKeysEnabled = enabled
}
override fun interceptBackKey(enabled: Boolean) {
Log.d("MainActivity", "Intercept back key: $enabled")
interceptBackKeyEnabled = enabled
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val keyName = keyEventMap[keyCode]
if (keyName != null) {
wv.evaluateJavascript(
"""
try {
window.onNativeKeyDown("$keyName", $keyCode)
} catch (err) {
false
}
""".trimIndent()
) { result ->
run {
if (result.equals("true", ignoreCase = true)) {
Log.d("Key Event", "Key event $keyName intercepted")
}
}
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (interceptVolumeKeysEnabled) {
true
} else {
super.onKeyDown(keyCode, event)
}
}
KeyEvent.KEYCODE_BACK -> {
if (interceptBackKeyEnabled) {
true
} else {
super.onKeyDown(keyCode, event)
}
}
else -> super.onKeyDown(keyCode, event)
}
}
return super.onKeyDown(keyCode, event)
}
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
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 ->
controller.hide(WindowInsets.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
)
}
}
}
@@ -2,8 +2,12 @@
<!-- Base application theme. -->
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:fitsSystemWindows">false</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>
@@ -17,3 +17,6 @@ schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.0.4", features = ["build"] }
schemars = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
font-enumeration = "0.9.0"
@@ -4,6 +4,20 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.os.Build
import android.view.View
import android.view.KeyEvent
import android.view.WindowInsets
import android.view.WindowManager
import android.view.WindowInsetsController
import android.graphics.Color
import android.webkit.WebView
import android.graphics.fonts.SystemFonts
import android.graphics.fonts.Font
import androidx.core.view.WindowCompat
import androidx.core.content.FileProvider
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.browser.customtabs.CustomTabsIntent
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
@@ -11,6 +25,7 @@ import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import org.json.JSONArray
import java.io.*
@InvokeArg
@@ -24,6 +39,28 @@ class CopyURIRequestArgs {
var dst: String? = null
}
@InvokeArg
class InstallPackageRequestArgs {
var path: String? = null
}
@InvokeArg
class SetSystemUIVisibilityRequestArgs {
var visible: Boolean? = false
var darkMode: Boolean? = false
}
@InvokeArg
class InterceptKeysRequestArgs {
var volumeKeys: Boolean? = null
var backKey: Boolean? = null
}
interface KeyDownInterceptor {
fun interceptVolumeKeys(enabled: Boolean)
fun interceptBackKey(enabled: Boolean)
}
@TauriPlugin
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
@@ -34,6 +71,40 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
var pendingInvoke: Invoke? = null
}
override fun load(webView: WebView) {
super.load(webView)
handleIntent(activity.intent)
}
override fun onNewIntent(intent: Intent) {
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
val uri = intent?.data ?: return
Log.e("NativeBridgePlugin", "Received intent: $uri")
when {
uri.scheme == "readest" && uri.host == "auth-callback" -> {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
pendingInvoke?.resolve(result)
pendingInvoke = null
}
intent.action == Intent.ACTION_VIEW -> {
try {
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: SecurityException) {
Log.e("NativeBridgePlugin", "Failed to take persistable URI permission: ${e.message}")
}
}
}
}
@Command
fun auth_with_custom_tab(invoke: Invoke) {
val args = invoke.parseArgs(AuthRequestArgs::class.java)
@@ -74,4 +145,180 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
@Command
fun install_package(invoke: Invoke) {
val args = invoke.parseArgs(InstallPackageRequestArgs::class.java)
val ret = JSObject()
try {
val file = File(args.path ?: "")
if (file.exists()) {
val intent = Intent(Intent.ACTION_VIEW)
val apkUri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", file)
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
val packageManager = activity.packageManager
val resolveInfos = packageManager.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfos) {
val packageName = resolveInfo.activityInfo.packageName
activity.grantUriPermission(packageName, apkUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
activity.startActivity(intent)
ret.put("success", true)
} else {
ret.put("success", false)
ret.put("error", "File does not exist")
}
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
@Command
fun set_system_ui_visibility(invoke: Invoke) {
val args = invoke.parseArgs(SetSystemUIVisibilityRequestArgs::class.java)
val visible = args.visible ?: false
var isDarkMode = args.darkMode ?: false
val ret = JSObject()
try {
val window = activity.window
val decorView = window.decorView
if (!visible) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setDecorFitsSystemWindows(false)
val controller = window.insetsController
if (controller != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
val compatController = WindowCompat.getInsetsController(window, decorView)
compatController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
if (isDarkMode) {
controller.setSystemBarsAppearance(
0,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
)
} else {
controller.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
)
}
if (visible) {
controller.show(WindowInsets.Type.statusBars())
} else {
controller.hide(WindowInsets.Type.systemBars())
}
}
} else {
val compatController = WindowCompat.getInsetsController(window, decorView)
compatController?.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (!isDarkMode) {
it.isAppearanceLightStatusBars = true
} else {
it.isAppearanceLightStatusBars = false
}
if (visible) {
it.show(WindowInsetsCompat.Type.statusBars())
} else {
it.hide(WindowInsetsCompat.Type.systemBars())
}
}
}
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
ret.put("success", true)
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
@Command
fun get_status_bar_height(invoke: Invoke) {
val ret = JSObject()
try {
val resourceId = activity.resources.getIdentifier("status_bar_height", "dimen", "android")
val height = if (resourceId > 0) {
activity.resources.getDimensionPixelSize(resourceId)
} else {
0
}
ret.put("height", height)
} catch (e: Exception) {
ret.put("height", -1)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
@Command
fun get_sys_fonts_list(invoke: Invoke) {
val ret = JSObject()
try {
val fontList = mutableListOf<String>()
val fontFileList = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val systemFonts = SystemFonts.getAvailableFonts()
for (font in systemFonts) {
val file = font.getFile()?: continue
if (file.isFile && (file.name.endsWith(".ttf", true) || file.name.endsWith(".otf", true))) {
fontFileList.add(file.name)
}
}
} else {
val fontDirs = listOf("/system/fonts", "/system/font", "/data/fonts")
for (dirPath in fontDirs) {
val dir = File(dirPath)
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { file ->
if (file.isFile && (file.name.endsWith(".ttf", true) || file.name.endsWith(".otf", true))) {
fontFileList.add(file.name)
}
}
}
}
}
for (fileFileName in fontFileList) {
var fontName = fileFileName
.replace(Regex("\\.(ttf|otf)$", RegexOption.IGNORE_CASE), "")
.trim()
}
ret.put("fonts", JSONArray(fontList))
} catch (e: Exception) {
ret.put("error", e.message)
}
invoke.resolve(ret)
}
@Command
fun intercept_keys(invoke: Invoke) {
val args = invoke.parseArgs(InterceptKeysRequestArgs::class.java)
if (activity is KeyDownInterceptor) {
when (args.backKey) {
true -> (activity as KeyDownInterceptor).interceptBackKey(true)
false -> (activity as KeyDownInterceptor).interceptBackKey(false)
else -> {}
}
when (args.volumeKeys) {
true -> (activity as KeyDownInterceptor).interceptVolumeKeys(true)
false -> (activity as KeyDownInterceptor).interceptVolumeKeys(false)
else -> {}
}
} else {
Log.e("NativeBridgePlugin", "Activity does not implement KeyDownInterceptor")
}
invoke.resolve()
}
}
@@ -3,6 +3,11 @@ const COMMANDS: &[&str] = &[
"auth_with_custom_tab",
"copy_uri_to_path",
"use_background_audio",
"install_package",
"set_system_ui_visibility",
"get_status_bar_height",
"get_sys_fonts_list",
"intercept_keys",
];
fn main() {
@@ -1,11 +1,28 @@
import AuthenticationServices
import AVFoundation
import AuthenticationServices
import CoreText
import MediaPlayer
import ObjectiveC
import SwiftRs
import Tauri
import UIKit
import WebKit
func getLocalizedDisplayName(familyName: String) -> String? {
let fontDescriptor = CTFontDescriptorCreateWithAttributes(
[
kCTFontFamilyNameAttribute: familyName
] as CFDictionary)
let font = CTFontCreateWithFontDescriptor(fontDescriptor, 0.0, nil)
var actualLanguage: Unmanaged<CFString>?
if let localizedName = CTFontCopyLocalizedName(font, kCTFontFamilyNameKey, &actualLanguage) {
return localizedName as String
}
return nil
}
class SafariAuthRequestArgs: Decodable {
let authUrl: String
}
@@ -14,8 +31,145 @@ class UseBackgroundAudioRequestArgs: Decodable {
let enabled: Bool
}
class SetSystemUIVisibilityRequestArgs: Decodable {
let visible: Bool
let darkMode: Bool
}
class InterceptKeysRequestArgs: Decodable {
let backKey: Bool?
let volumeKeys: Bool?
}
class VolumeKeyHandler: NSObject {
private var audioSession: AVAudioSession?
private var originalVolume: Float = 0.0
private var volumeView: MPVolumeView?
private var isIntercepting = false
private var webView: WKWebView?
private var volumeSlider: UISlider?
func startInterception(webView: WKWebView) {
if isIntercepting {
return
}
self.webView = webView
isIntercepting = true
audioSession = AVAudioSession.sharedInstance()
do {
try audioSession?.setActive(true)
} catch {
print("Failed to activate audio session: \(error)")
}
originalVolume = audioSession?.outputVolume ?? 0.1
DispatchQueue.main.async { [weak self] in
self?.setupHiddenVolumeView()
}
audioSession?.addObserver(self, forKeyPath: "outputVolume", options: [.new], context: nil)
}
func stopInterception() {
if !isIntercepting {
return
}
isIntercepting = false
audioSession?.removeObserver(self, forKeyPath: "outputVolume")
DispatchQueue.main.async { [weak self] in
self?.setSystemVolume(self?.originalVolume ?? 0.1)
self?.volumeView?.removeFromSuperview()
self?.volumeView = nil
self?.volumeSlider = nil
}
do {
try audioSession?.setActive(false)
} catch {
print("Failed to deactivate audio session: \(error)")
}
}
private func setSystemVolume(_ volume: Float) {
DispatchQueue.main.async { [weak self] in
self?.volumeSlider?.value = volume
}
}
private func setupHiddenVolumeView() {
assert(Thread.isMainThread, "setupHiddenVolumeView must be called on main thread")
let frame = CGRect(x: -1000, y: -1000, width: 1, height: 1)
volumeView = MPVolumeView(frame: frame)
volumeSlider = volumeView?.subviews.first(where: { $0 is UISlider }) as? UISlider
if let window = UIApplication.shared.windows.first {
window.addSubview(volumeView!)
}
setSystemVolume(originalVolume)
}
override func observeValue(
forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
if keyPath == "outputVolume", let audioSession = self.audioSession, isIntercepting {
let currentVolume = audioSession.outputVolume
if currentVolume > originalVolume {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"window.onNativeKeyDown('VolumeUp');", completionHandler: nil)
self?.setSystemVolume(self?.originalVolume ?? 0.1)
}
} else if currentVolume < originalVolume {
DispatchQueue.main.async { [weak self] in
self?.webView?.evaluateJavaScript(
"window.onNativeKeyDown('VolumeDown');", completionHandler: nil)
self?.setSystemVolume(self?.originalVolume ?? 0.1)
}
}
}
}
}
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
private var webView: WKWebView?
@objc public override func load(webview: WKWebView) {
self.webView = webview
print("NativeBridgePlugin loaded")
}
private struct AssociatedKeys {
static var volumeKeyHandler = "volumeKeyHandler"
static var interceptingVolumeKeys = "interceptingVolumeKeys"
}
private var volumeKeyHandler: VolumeKeyHandler? {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.volumeKeyHandler) as? VolumeKeyHandler
}
set {
objc_setAssociatedObject(
self, &AssociatedKeys.volumeKeyHandler, newValue, .OBJC_ASSOCIATION_RETAIN)
}
}
private var interceptingVolumeKeys: Bool {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.interceptingVolumeKeys) as? Bool
?? false
}
set {
objc_setAssociatedObject(
self, &AssociatedKeys.interceptingVolumeKeys, newValue, .OBJC_ASSOCIATION_RETAIN)
}
}
@objc public func use_background_audio(_ invoke: Invoke) {
do {
@@ -66,6 +220,81 @@ class NativeBridgePlugin: Plugin {
let started = authSession?.start() ?? false
Logger.info("Auth session start result: \(started)")
}
@objc public func set_system_ui_visibility(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(SetSystemUIVisibilityRequestArgs.self)
let visible = args.visible
let darkMode = args.darkMode
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = !visible
UIApplication.shared.setStatusBarHidden(!visible, with: .none)
let windows = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
let keyWindow = windows.first(where: { $0.isKeyWindow }) ?? windows.first
if let keyWindow = keyWindow {
keyWindow.overrideUserInterfaceStyle = darkMode ? .dark : .light
keyWindow.layoutIfNeeded()
} else {
print("No key window found")
}
}
invoke.resolve(["success": true])
}
@objc public func get_sys_fonts_list(_ invoke: Invoke) throws {
var fontList: [String] = []
for family in UIFont.familyNames.sorted() {
if let localized = getLocalizedDisplayName(familyName: family) {
fontList.append(localized)
} else {
let fontNames = UIFont.fontNames(forFamilyName: family)
if fontNames.isEmpty {
fontList.append(family)
} else {
fontList.append(contentsOf: fontNames)
}
}
}
invoke.resolve(["fonts": fontList])
}
private func interceptVolumeKeys(_ intercept: Bool) {
interceptingVolumeKeys = intercept
if intercept {
if volumeKeyHandler == nil {
volumeKeyHandler = VolumeKeyHandler()
}
if let webView = self.webView {
volumeKeyHandler?.startInterception(webView: webView)
}
} else {
volumeKeyHandler?.stopInterception()
}
}
@objc public func intercept_keys(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(InterceptKeysRequestArgs.self)
if let volumeKeys = args.volumeKeys {
DispatchQueue.main.async { [weak self] in
self?.interceptVolumeKeys(volumeKeys)
}
}
invoke.resolve()
} catch {
invoke.reject(error.localizedDescription)
}
}
}
@_cdecl("init_plugin_native_bridge")
@@ -78,4 +307,4 @@ extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return UIApplication.shared.windows.first ?? UIWindow()
}
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-status-bar-height"
description = "Enables the get_status_bar_height command without any pre-configured scope."
commands.allow = ["get_status_bar_height"]
[[permission]]
identifier = "deny-get-status-bar-height"
description = "Denies the get_status_bar_height command without any pre-configured scope."
commands.deny = ["get_status_bar_height"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-sys-fonts-list"
description = "Enables the get_sys_fonts_list command without any pre-configured scope."
commands.allow = ["get_sys_fonts_list"]
[[permission]]
identifier = "deny-get-sys-fonts-list"
description = "Denies the get_sys_fonts_list command without any pre-configured scope."
commands.deny = ["get_sys_fonts_list"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-install-package"
description = "Enables the install_package command without any pre-configured scope."
commands.allow = ["install_package"]
[[permission]]
identifier = "deny-install-package"
description = "Denies the install_package command without any pre-configured scope."
commands.deny = ["install_package"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-intercept-keys"
description = "Enables the intercept_keys command without any pre-configured scope."
commands.allow = ["intercept_keys"]
[[permission]]
identifier = "deny-intercept-keys"
description = "Denies the intercept_keys command without any pre-configured scope."
commands.deny = ["intercept_keys"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-system-ui-visibility"
description = "Enables the set_system_ui_visibility command without any pre-configured scope."
commands.allow = ["set_system_ui_visibility"]
[[permission]]
identifier = "deny-set-system-ui-visibility"
description = "Denies the set_system_ui_visibility command without any pre-configured scope."
commands.deny = ["set_system_ui_visibility"]
@@ -8,6 +8,11 @@ Default permissions for the plugin
- `allow-auth-with-custom-tab`
- `allow-copy-uri-to-path`
- `allow-use-background-audio`
- `allow-install-package`
- `allow-set-system-ui-visibility`
- `allow-get-status-bar-height`
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
## Permission Table
@@ -99,6 +104,136 @@ Denies the copy_uri_to_path command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-status-bar-height`
</td>
<td>
Enables the get_status_bar_height command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-status-bar-height`
</td>
<td>
Denies the get_status_bar_height command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-get-sys-fonts-list`
</td>
<td>
Enables the get_sys_fonts_list command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-sys-fonts-list`
</td>
<td>
Denies the get_sys_fonts_list command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-install-package`
</td>
<td>
Enables the install_package command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-install-package`
</td>
<td>
Denies the install_package command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-intercept-keys`
</td>
<td>
Enables the intercept_keys command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-intercept-keys`
</td>
<td>
Denies the intercept_keys command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-set-system-ui-visibility`
</td>
<td>
Enables the set_system_ui_visibility command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-set-system-ui-visibility`
</td>
<td>
Denies the set_system_ui_visibility command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-use-background-audio`
</td>
@@ -1,3 +1,13 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari", "allow-auth-with-custom-tab", "allow-copy-uri-to-path", "allow-use-background-audio"]
permissions = [
"allow-auth-with-safari",
"allow-auth-with-custom-tab",
"allow-copy-uri-to-path",
"allow-use-background-audio",
"allow-install-package",
"allow-set-system-ui-visibility",
"allow-get-status-bar-height",
"allow-get-sys-fonts-list",
"allow-intercept-keys",
]
@@ -330,6 +330,66 @@
"const": "deny-copy-uri-to-path",
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
"type": "string",
"const": "allow-get-status-bar-height",
"markdownDescription": "Enables the get_status_bar_height command without any pre-configured scope."
},
{
"description": "Denies the get_status_bar_height command without any pre-configured scope.",
"type": "string",
"const": "deny-get-status-bar-height",
"markdownDescription": "Denies the get_status_bar_height command without any pre-configured scope."
},
{
"description": "Enables the get_sys_fonts_list command without any pre-configured scope.",
"type": "string",
"const": "allow-get-sys-fonts-list",
"markdownDescription": "Enables the get_sys_fonts_list command without any pre-configured scope."
},
{
"description": "Denies the get_sys_fonts_list command without any pre-configured scope.",
"type": "string",
"const": "deny-get-sys-fonts-list",
"markdownDescription": "Denies the get_sys_fonts_list command without any pre-configured scope."
},
{
"description": "Enables the install_package command without any pre-configured scope.",
"type": "string",
"const": "allow-install-package",
"markdownDescription": "Enables the install_package command without any pre-configured scope."
},
{
"description": "Denies the install_package command without any pre-configured scope.",
"type": "string",
"const": "deny-install-package",
"markdownDescription": "Denies the install_package command without any pre-configured scope."
},
{
"description": "Enables the intercept_keys command without any pre-configured scope.",
"type": "string",
"const": "allow-intercept-keys",
"markdownDescription": "Enables the intercept_keys command without any pre-configured scope."
},
{
"description": "Denies the intercept_keys command without any pre-configured scope.",
"type": "string",
"const": "deny-intercept-keys",
"markdownDescription": "Denies the intercept_keys command without any pre-configured scope."
},
{
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
"const": "allow-set-system-ui-visibility",
"markdownDescription": "Enables the set_system_ui_visibility command without any pre-configured scope."
},
{
"description": "Denies the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
"const": "deny-set-system-ui-visibility",
"markdownDescription": "Denies the set_system_ui_visibility command without any pre-configured scope."
},
{
"description": "Enables the use_background_audio command without any pre-configured scope.",
"type": "string",
@@ -343,10 +403,10 @@
"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`",
"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`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`",
"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`"
"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`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`"
}
]
}
@@ -35,3 +35,41 @@ pub(crate) async fn use_background_audio<R: Runtime>(
) -> Result<()> {
app.native_bridge().use_background_audio(payload)
}
#[command]
pub(crate) async fn install_package<R: Runtime>(
app: AppHandle<R>,
payload: InstallPackageRequest,
) -> Result<InstallPackageResponse> {
app.native_bridge().install_package(payload)
}
#[command]
pub(crate) async fn set_system_ui_visibility<R: Runtime>(
app: AppHandle<R>,
payload: SetSystemUIVisibilityRequest,
) -> Result<SetSystemUIVisibilityResponse> {
app.native_bridge().set_system_ui_visibility(payload)
}
#[command]
pub(crate) async fn get_status_bar_height<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetStatusBarHeightResponse> {
app.native_bridge().get_status_bar_height()
}
#[command]
pub(crate) async fn get_sys_fonts_list<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetSysFontsListResponse> {
app.native_bridge().get_sys_fonts_list()
}
#[command]
pub(crate) async fn intercept_keys<R: Runtime>(
app: AppHandle<R>,
payload: InterceptKeysRequest,
) -> Result<()> {
app.native_bridge().intercept_keys(payload)
}
@@ -29,4 +29,35 @@ impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn install_package(
&self,
_payload: InstallPackageRequest,
) -> crate::Result<InstallPackageResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_system_ui_visibility(
&self,
_payload: SetSystemUIVisibilityRequest,
) -> crate::Result<SetSystemUIVisibilityResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
let font_collection = font_enumeration::Collection::new().unwrap();
let mut fonts = Vec::new();
for font in font_collection.all() {
fonts.push(font.font_name.clone());
}
Ok(GetSysFontsListResponse { fonts, error: None })
}
pub fn intercept_keys(&self, _payload: InterceptKeysRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -41,6 +41,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::auth_with_custom_tab,
commands::copy_uri_to_path,
commands::use_background_audio,
commands::install_package,
commands::set_system_ui_visibility,
commands::get_status_bar_height,
commands::get_sys_fonts_list,
commands::intercept_keys,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -55,3 +55,49 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn install_package(
&self,
payload: InstallPackageRequest,
) -> crate::Result<InstallPackageResponse> {
self.0
.run_mobile_plugin("install_package", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn set_system_ui_visibility(
&self,
payload: SetSystemUIVisibilityRequest,
) -> crate::Result<SetSystemUIVisibilityResponse> {
self.0
.run_mobile_plugin("set_system_ui_visibility", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
self.0
.run_mobile_plugin("get_status_bar_height", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_sys_fonts_list(&self) -> crate::Result<GetSysFontsListResponse> {
self.0
.run_mobile_plugin("get_sys_fonts_list", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn intercept_keys(&self, payload: InterceptKeysRequest) -> crate::Result<()> {
self.0
.run_mobile_plugin("intercept_keys", payload)
.map_err(Into::into)
}
}
@@ -31,3 +31,51 @@ pub struct CopyURIResponse {
pub struct UseBackgroundAudioRequest {
pub enabled: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPackageRequest {
pub path: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPackageResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetSystemUIVisibilityRequest {
pub visible: bool,
pub dark_mode: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetSystemUIVisibilityResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetStatusBarHeightResponse {
pub height: u32,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSysFontsListResponse {
pub fonts: Vec<String>,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InterceptKeysRequest {
pub volume_keys: Option<bool>,
pub back_key: Option<bool>,
}
+38 -24
View File
@@ -71,7 +71,10 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
let files = files
.into_iter()
.map(|f| {
let file = f.to_string_lossy().replace("\\", "\\\\");
let file = f
.to_string_lossy()
.replace("\\", "\\\\")
.replace("\"", "\\\"");
format!("\"{file}\"",)
})
.collect::<Vec<_>>()
@@ -102,17 +105,6 @@ async fn start_server(window: Window) -> Result<u16, String> {
.map_err(|err| err.to_string())
}
#[cfg(desktop)]
#[command]
async fn list_fonts() -> Result<Vec<String>, String> {
let font_collection = font_enumeration::Collection::new().unwrap();
let mut fonts = Vec::new();
for font in font_collection.all() {
fonts.push(font.family_name.clone());
}
Ok(fonts)
}
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
@@ -135,8 +127,6 @@ pub fn run() {
macos::apple_auth::start_apple_sign_in,
#[cfg(target_os = "macos")]
macos::traffic_light::set_traffic_lights,
#[cfg(desktop)]
list_fonts
])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
@@ -201,36 +191,58 @@ pub fn run() {
let app_handle = app.handle().clone();
app.listen("window-ready", move |_| {
app_handle.get_webview_window("main").unwrap()
.eval("window.__READEST_CLI_ACCESS = true; window.__READEST_UPDATER_ACCESS = true;")
app_handle
.get_webview_window("main")
.unwrap()
.eval("window.__READEST_CLI_ACCESS = true;")
.expect("Failed to set cli access config");
set_rounded_window(&app_handle, true);
#[cfg(target_os = "windows")]
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
if tauri_plugin_os::version()
<= tauri_plugin_os::Version::from_string("10.0.19045")
{
set_rounded_window(&app_handle, false);
}
});
}
#[cfg(any(windows, target_os = "linux"))]
#[cfg(target_os = "windows")]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
app.handle().plugin(
#[cfg(target_os = "linux")]
{
fn has_xdg_mime() -> bool {
std::process::Command::new("which")
.arg("xdg-mime")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
if has_xdg_mime() {
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
} else {
println!("xdg-mime not found; skipping deep link setup on Linux.");
}
}
if let Err(e) = app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
) {
eprintln!("Failed to initialize tauri_plugin_log: {}", e);
};
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
#[cfg(desktop)]
let win_builder = win_builder
.inner_size(800.0, 600.0)
.resizable(true);
let win_builder = win_builder.inner_size(800.0, 600.0).resizable(true);
#[cfg(target_os = "macos")]
let win_builder = win_builder
@@ -247,7 +259,9 @@ pub fn run() {
.title("Readest");
if cfg!(target_os = "windows") {
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
if tauri_plugin_os::version()
<= tauri_plugin_os::Version::from_string("10.0.19045")
{
win_builder = win_builder.shadow(false);
} else {
win_builder = win_builder.shadow(true);
+8 -3
View File
@@ -17,15 +17,20 @@
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://fontsapi.zeoseven.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://fontsapi.zeoseven.com",
"frame-src": "'self' blob: asset: http://asset.localhost",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
},
"assetProtocol": {
"enable": true,
"scope": {
"allow": ["$RESOURCE/**", "$APPDATA/**/*", "$TEMP/**/*"],
"allow": [
"$RESOURCE/**",
"$APPDATA/**/*",
"$TEMP/**/*",
"/private/var/mobile/Containers/Data/Application/**/*"
],
"deny": []
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { useTheme } from '@/hooks/useTheme';
export default function AuthErrorPage() {
const router = useRouter();
useTheme();
useTheme({ systemUIVisible: false });
useEffect(() => {
const timer = setTimeout(() => {
+3
View File
@@ -13,6 +13,7 @@ import { IoArrowBack } from 'react-icons/io5';
import { useAuth } from '@/context/AuthContext';
import { supabase } from '@/utils/supabase';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -74,6 +75,8 @@ export default function AuthPage() {
const headerRef = useRef<HTMLDivElement>(null);
useTheme({ systemUIVisible: false });
const getTauriRedirectTo = (isOAuth: boolean) => {
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
if (appService?.isMobile) {
@@ -1,14 +1,16 @@
import clsx from 'clsx';
import Image from 'next/image';
import { Book } from '@/types/book';
import { LibraryViewModeType } from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
interface BookCoverProps {
book: Book;
isPreview?: boolean;
mode?: LibraryViewModeType;
}
const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
const BookCover: React.FC<BookCoverProps> = ({ mode = 'grid', book, isPreview }) => {
return (
<div className='relative flex h-full w-full'>
<Image
@@ -31,8 +33,8 @@ const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : 'line-clamp-3',
isPreview ? 'text-[0.5em]' : 'text-lg',
isPreview ? 'line-clamp-2' : mode === 'grid' ? 'line-clamp-3' : 'line-clamp-2',
isPreview ? 'text-[0.5em]' : mode === 'grid' ? 'text-lg' : 'text-sm',
)}
>
{formatTitle(book.title)}
@@ -43,7 +45,7 @@ const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : 'text-base',
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
{formatAuthors(book.author)}
@@ -6,10 +6,13 @@ import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/li
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryViewModeType } from '@/types/settings';
import { formatAuthors } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
interface BookItemProps {
mode: LibraryViewModeType;
book: Book;
isSelectMode: boolean;
selectedBooks: string[];
@@ -20,6 +23,7 @@ interface BookItemProps {
}
const BookItem: React.FC<BookItemProps> = ({
mode,
book,
isSelectMode,
selectedBooks,
@@ -39,12 +43,19 @@ const BookItem: React.FC<BookItemProps> = ({
return (
<div
className={clsx(
'book-item flex h-full flex-col',
'book-item flex',
mode === 'grid' && 'h-full flex-col',
mode === 'list' && 'h-28 flex-row gap-4 overflow-hidden',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md'>
<BookCover book={book} />
<div
className={clsx(
'bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md',
mode === 'list' && 'min-w-20',
)}
>
<BookCover mode={mode} book={book} />
{selectedBooks.includes(book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
@@ -58,11 +69,28 @@ const BookItem: React.FC<BookItemProps> = ({
</div>
)}
</div>
<div className={clsx('flex w-full flex-col p-0 pt-2')}>
<div className='min-w-0 flex-1'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
<div
className={clsx(
'flex w-full flex-col p-0',
mode === 'grid' && 'pt-2',
mode === 'list' && 'py-2',
)}
>
<div className={clsx('min-w-0 flex-1', mode === 'list' && 'flex flex-col gap-2')}>
<h4
className={clsx(
'overflow-hidden text-ellipsis font-semibold',
mode === 'grid' && 'block whitespace-nowrap text-[0.6em] text-xs',
mode === 'list' && 'line-clamp-2 text-base',
)}
>
{book.title}
</h4>
{mode === 'list' && (
<p className='text-neutral-content line-clamp-1 text-sm'>
{formatAuthors(book.author, book.primaryLanguage) || ''}
</p>
)}
</div>
<div
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
@@ -6,7 +6,9 @@ import { MdDelete, MdOpenInNew, MdOutlineCancel } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
import { LibraryViewModeType } from '@/types/settings';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
@@ -15,7 +17,7 @@ import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import BookshelfItem, { generateGridItems, generateListItems } from './BookshelfItem';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
@@ -45,6 +47,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
@@ -53,10 +56,16 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [queryTerm, setQueryTerm] = useState<string | null>(null);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const [viewMode, setViewMode] = useState(searchParams?.get('view') || settings.libraryViewMode);
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || settings.librarySortBy);
const [sortOrder, setSortOrder] = useState(
searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc'),
);
const isImportingBook = useRef(false);
const { setLibrary } = useLibraryStore();
const allBookshelfItems = generateBookshelfItems(libraryBooks);
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
useEffect(() => {
if (isSelectMode) {
@@ -89,23 +98,55 @@ const Bookshelf: React.FC<BookshelfProps> = ({
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
const view = searchParams?.get('view') || settings.libraryViewMode;
const sort = searchParams?.get('sort') || settings.librarySortBy;
const order = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
const params = new URLSearchParams(searchParams?.toString());
if (query) {
params.set('q', query);
setQueryTerm(query);
} else {
params.delete('q');
setQueryTerm(null);
}
if (sort) {
params.set('sort', sort);
setSortBy(sort);
} else {
params.delete('sort');
}
if (order) {
params.set('order', order);
setSortOrder(order);
} else {
params.delete('order');
}
if (view) {
params.set('view', view);
setViewMode(view);
} else {
params.delete('view');
}
if (sort === 'updated' && order === 'desc' && view === 'grid') {
params.delete('sort');
params.delete('order');
params.delete('view');
}
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
) as BooksGroup;
if (booksGroup) {
setNavBooksGroup(booksGroup);
params.set('group', group);
} else {
navigateToLibrary(router, query ? `q=${query}` : undefined);
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
} else {
setNavBooksGroup(null);
navigateToLibrary(router, query ? `q=${query}` : undefined);
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
@@ -152,23 +193,65 @@ const Bookshelf: React.FC<BookshelfProps> = ({
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;
});
const bookSorter = (a: Book, b: Book) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
switch (sortBy) {
case 'title':
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author);
const bAuthors = formatAuthors(b.author);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case 'created':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
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;
})
.sort((a, b) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
if (sortBy === 'updated') {
return (
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
);
} else if ('name' in a || 'name' in b) {
const aName = 'name' in a ? a.name : formatTitle(a.title);
const bName = 'name' in b ? b.name : formatTitle(b.title);
return aName.localeCompare(bName, uiLanguage || navigator.language) * sortOrderMultiplier;
} else if (!('name' in a || 'name' in b)) {
return bookSorter(a, b) * sortOrderMultiplier;
} else {
return 0;
}
});
return (
<div className='bookshelf'>
<div
className={clsx(
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
'transform-wrapper',
viewMode === 'grid' && 'grid flex-1 grid-cols-3 gap-x-4 px-4 sm:gap-x-0 sm:px-2',
viewMode === 'grid' && 'sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
viewMode === 'list' && 'flex flex-col',
)}
>
{filteredBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
mode={viewMode as LibraryViewModeType}
item={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
@@ -184,7 +267,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
/>
))}
{!navBooksGroup && allBookshelfItems.length > 0 && (
{viewMode === 'grid' && !navBooksGroup && allBookshelfItems.length > 0 && (
<div
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
@@ -10,6 +10,7 @@ import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getLocalBookFilename } from '@/utils/book';
import { LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
@@ -18,7 +19,7 @@ import GroupItem from './GroupItem';
export type BookshelfItem = Book | BooksGroup;
export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => {
export const generateGridItems = (books: Book[]): (Book | BooksGroup)[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
book.groupId = book.groupId || BOOK_UNGROUPED_ID;
@@ -47,6 +48,10 @@ export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] =>
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateListItems = (books: Book[]): (Book | BooksGroup)[] => {
return books.filter((book) => !book.deletedAt).sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateGroupsList = (items: Book[]): BookGroupType[] => {
return items
.sort((a, b) => b.updatedAt - a.updatedAt)
@@ -66,6 +71,7 @@ export const generateGroupsList = (items: Book[]): BookGroupType[] => {
};
interface BookshelfItemProps {
mode: LibraryViewModeType;
item: BookshelfItem;
isSelectMode: boolean;
selectedBooks: string[];
@@ -80,6 +86,7 @@ interface BookshelfItemProps {
}
const BookshelfItem: React.FC<BookshelfItemProps> = ({
mode,
item,
isSelectMode,
selectedBooks,
@@ -248,30 +255,35 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
return (
<div
className={clsx(
'sm:hover:bg-base-300/50 group flex h-full flex-col px-0 py-4 sm:px-4',
pressing ? 'scale-95' : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div
className={clsx(
'group',
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
pressing ? (mode === 'grid' ? 'scale-95' : 'scale-98') : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
mode={mode}
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
)}
</div>
</div>
</div>
);
@@ -4,20 +4,23 @@ 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 { PiDotsThreeCircle } from 'react-icons/pi';
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
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 useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
import ImportMenu from './ImportMenu';
import useShortcuts from '@/hooks/useShortcuts';
import SortMenu from './SortMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
@@ -34,6 +37,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { statusBarHeight } = useThemeStore();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
@@ -44,7 +48,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
useShortcuts({
@@ -90,10 +94,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '',
}}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
@@ -173,7 +181,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={iconSize16} />}
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
>
<SortMenu />
</Dropdown>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={iconSize18} />}
>
<SettingsMenu />
</Dropdown>
@@ -6,12 +6,13 @@ import { PiUserCircleCheck } from 'react-icons/pi';
import { MdCheck } from 'react-icons/md';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { hasUpdater, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
@@ -19,11 +20,11 @@ import { QuotaType } from '@/types/user';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
interface BookMenuProps {
interface SettingsMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
@@ -37,6 +38,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
);
const iconSize = useResponsiveSize(16);
const showAboutReadest = () => {
setAboutDialogVisible(true);
@@ -132,7 +134,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-72 shadow-2xl'
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
{user ? (
<MenuItem
@@ -142,45 +144,45 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
: _('Logged in')
}
labelClass='!max-w-40'
icon={
Icon={
avatarUrl ? (
<Image
src={avatarUrl}
alt={_('User avatar')}
className='h-5 w-5 rounded-full'
className='rounded-full'
referrerPolicy='no-referrer'
width={20}
height={20}
width={iconSize}
height={iconSize}
/>
) : (
<PiUserCircleCheck />
PiUserCircleCheck
)
}
>
<ul>
<Quota quotas={quotas} className='h-10 pl-4 pr-2' />
<Quota quotas={quotas} className='h-10 pl-3 pr-2' />
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
</ul>
</MenuItem>
) : (
<MenuItem label={_('Sign In')} icon={<PiUserCircle />} onClick={handleUserLogin}></MenuItem>
<MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem>
)}
<MenuItem
label={_('Auto Upload Books to Cloud')}
icon={isAutoUpload ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoUpload ? MdCheck : undefined}
onClick={toggleAutoUploadBooks}
/>
{isTauriAppPlatform() && !appService?.isMobile && (
<MenuItem
label={_('Auto Import on File Open')}
icon={isAutoImportBooksOnOpen ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoImportBooksOnOpen ? MdCheck : undefined}
onClick={toggleAutoImportBooksOnOpen}
/>
)}
{hasUpdater() && (
{appService?.hasUpdater && (
<MenuItem
label={_('Check Updates on Start')}
icon={isAutoCheckUpdates ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoCheckUpdates ? MdCheck : undefined}
onClick={toggleAutoCheckUpdates}
/>
)}
@@ -189,13 +191,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
{appService?.hasWindow && (
<MenuItem
label={_('Always on Top')}
icon={isAlwaysOnTop ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAlwaysOnTop ? MdCheck : undefined}
onClick={toggleAlwaysOnTop}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
Icon={isScreenWakeLock ? MdCheck : undefined}
onClick={toggleScreenWakeLock}
/>
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
@@ -0,0 +1,121 @@
import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { MdCheck } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { LibrarySortByType, LibraryViewModeType } from '@/types/settings';
import { navigateToLibrary } from '@/utils/nav';
import MenuItem from '@/components/MenuItem';
interface SortMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const viewMode = settings.libraryViewMode;
const sortBy = settings.librarySortBy;
const isAscending = settings.librarySortAscending;
const viewOptions = [
{ label: _('List'), value: 'list' },
{ label: _('Grid'), value: 'grid' },
];
const sortByOptions = [
{ label: _('Title'), value: 'title' },
{ label: _('Author'), value: 'author' },
{ label: _('Format'), value: 'format' },
{ label: _('Date Read'), value: 'updated' },
{ label: _('Date Added'), value: 'created' },
];
const sortingOptions = [
{ label: _('Ascending'), value: true },
{ label: _('Descending'), value: false },
];
const handleSetViewMode = (value: LibraryViewModeType) => {
settings.libraryViewMode = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('view', value);
navigateToLibrary(router, `${params.toString()}`);
};
const handleSetSortBy = (value: LibrarySortByType) => {
settings.librarySortBy = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('sort', value);
navigateToLibrary(router, `${params.toString()}`);
};
const handleSetSortAscending = (value: boolean) => {
settings.librarySortAscending = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('order', value ? 'asc' : 'desc');
navigateToLibrary(router, `${params.toString()}`);
};
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
{viewOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
Icon={viewMode === option.value ? MdCheck : undefined}
onClick={() => handleSetViewMode(option.value as LibraryViewModeType)}
/>
))}
<hr className='border-base-200 my-1' />
<MenuItem
label={_('Sort by...')}
buttonClass='h-8'
labelClass='text-sm sm:text-xs'
disabled
/>
{sortByOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
Icon={sortBy === option.value ? MdCheck : undefined}
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
/>
))}
<hr className='border-base-200 my-1' />
{sortingOptions.map((option) => (
<MenuItem
key={option.value.toString()}
label={option.label}
buttonClass='h-8'
Icon={isAscending === option.value ? MdCheck : undefined}
onClick={() => handleSetSortAscending(option.value)}
/>
))}
</div>
);
};
export default SortMenu;
@@ -98,11 +98,12 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
if (newBook.uploadedAt && !newBook.deletedAt) {
try {
await appService?.downloadBook(newBook, true);
} catch {
console.error('Failed to download book:', newBook);
} finally {
newBook.coverImageUrl = await appService?.generateCoverImageUrl(newBook);
updatedLibrary.push(newBook);
setLibrary(updatedLibrary);
} catch {
console.error('Failed to download book:', newBook);
}
}
}
+29 -15
View File
@@ -12,8 +12,8 @@ import { getFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
@@ -21,7 +21,6 @@ import { getCurrentWebview } from '@tauri-apps/api/webview';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -29,11 +28,17 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useTheme } from '@/hooks/useTheme';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useThemeStore } from '@/store/themeStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { tauriHandleSetAlwaysOnTop, tauriQuitApp } from '@/utils/window';
import {
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
tauriQuitApp,
} from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
@@ -59,9 +64,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setCheckOpenWithBooks,
} = useLibraryStore();
const _ = useTranslation();
useTheme();
const { updateAppTheme } = useThemeStore();
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
const { settings, setSettings, saveSettings } = useSettingsStore();
const { statusBarHeight } = useThemeStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -86,6 +91,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useScreenWakeLock(settings.screenWakeLock);
useShortcuts({
onToggleFullscreen: async () => {
if (isTauriAppPlatform()) {
await tauriHandleToggleFullScreen();
}
},
onQuitApp: async () => {
if (isTauriAppPlatform()) {
await tauriQuitApp();
@@ -93,14 +103,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
},
});
useEffect(() => {
updateAppTheme('base-200');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const doCheckAppUpdates = async () => {
if (hasUpdater() && settings.autoCheckUpdates) {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
}
};
@@ -203,7 +208,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
for (const file of openWithFiles) {
console.log('Open with book:', file);
try {
const temp = !settings.autoImportBooksOnOpen;
const temp = appService.isMobile ? false : !settings.autoImportBooksOnOpen;
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
if (book) {
bookIds.push(book.hash);
@@ -532,11 +537,16 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<div
ref={containerRef}
className={clsx(
'scroll-container drop-zone mt-[48px] flex-grow overflow-y-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(52px+env(safe-area-inset-top))]',
'scroll-container drop-zone flex-grow overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[52px]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '48px',
}}
>
<DropIndicator />
<Bookshelf
@@ -574,9 +584,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={() => setShowDetailsBook(null)}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
/>
)}
<AboutWindow />
{appService?.isAndroidApp && <UpdaterWindow />}
<Toast />
</div>
);
@@ -6,6 +6,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePageFlip } from '../hooks/usePageFlip';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
@@ -138,8 +139,9 @@ const FoliateViewer: React.FC<{
}
};
const { handlePageFlip } = usePageFlip(bookKey, viewRef, containerRef);
useTouchEvent(bookKey, viewRef);
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
useClickEvent(bookKey, handlePageFlip);
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
@@ -223,7 +225,7 @@ const FoliateViewer: React.FC<{
<>
<div
className='foliate-viewer h-[100%] w-[100%]'
onClick={(event) => handleTurnPage(event)}
onClick={(event) => handlePageFlip(event)}
ref={containerRef}
/>
</>
@@ -103,7 +103,6 @@ const FooterBar: React.FC<FooterBarProps> = ({
};
const handleSetActionTab = (tab: string) => {
console.log('handleSetActionTab', tab);
setActionTab(actionTab === tab ? '' : tab);
if (tab === 'tts') {
setHoveredBookKey('');
@@ -130,9 +129,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
const isVisible = hoveredBookKey === bookKey;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction = progressValid
? ((progressInfo!.next ?? progressInfo!.current) + 1) / progressInfo!.total
: 0;
const progressFraction =
progressValid && progressInfo?.total > 0
? ((progressInfo!.next ?? progressInfo!.current) + 1) / progressInfo!.total || 0
: 0;
return (
<>
@@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
import { getFootnoteStyles, getStyles, getThemeCode, mountAdditionalFonts } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
@@ -53,6 +53,10 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
getView(bookKey)?.goTo(popupLinkDetail.href);
});
});
view.addEventListener('load', (e: CustomEvent) => {
const { doc } = e.detail;
mountAdditionalFonts(doc);
});
footnoteViewRef.current = view;
footnoteRef.current?.replaceChildren(view);
const { renderer } = view;
@@ -131,6 +135,11 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
setGridRect(rect);
setTrianglePosition(triangPos);
const { a: anchor } = detail as { a: HTMLAnchorElement };
const footnoteClasses = ['duokan-footnote', 'footnote-link', 'footnote'];
if (footnoteClasses.some((cls) => anchor.classList.contains(cls))) {
detail['follow'] = true;
}
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
console.warn(err);
const detail = (event as CustomEvent).detail;
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
@@ -43,6 +44,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
} = useTrafficLightStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { bookKeys, hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { isSideBarVisible } = useSidebarStore();
const iconSize16 = useResponsiveSize(16);
@@ -89,20 +91,27 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
'bg-base-100 absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
isVisible ? 'visible' : 'hidden',
)}
style={{
height: systemUIVisible ? `max(env(safe-area-inset-top), ${statusBarHeight}px)` : '',
}}
/>
<div
ref={headerRef}
className={clsx(
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
`shadow-xs transition-opacity duration-300`,
`shadow-xs transition-[opacity,margin-top] duration-300`,
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
appService?.hasRoundedWindow && 'rounded-window-top-right',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
isHoveredAnim && 'hover-bar-anim',
isVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
isDropdownOpen && 'header-bar-pinned',
)}
style={{
marginTop: systemUIVisible
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: 'env(safe-area-inset-top)',
}}
onMouseLeave={() => !appService?.isMobile && setHoveredBookKey('')}
>
<div className='sidebar-bookmark-toggler z-20 flex h-full items-center gap-x-4'>
@@ -139,7 +148,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
showMaximize={
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
}
onClose={() => onCloseBook(bookKey)}
onClose={() => {
setHoveredBookKey(null);
onCloseBook(bookKey);
}}
/>
</div>
</div>
@@ -1,6 +1,8 @@
import React from 'react';
import { LuNotebookPen } from 'react-icons/lu';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -13,11 +15,16 @@ interface NotebookTogglerProps {
const NotebookToggler: React.FC<NotebookTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setHoveredBookKey } = useReaderStore();
const { sideBarBookKey, setSideBarBookKey } = useSidebarStore();
const { isNotebookVisible, toggleNotebook } = useNotebookStore();
const iconSize16 = useResponsiveSize(16);
const handleToggleSidebar = () => {
if (appService?.isMobile) {
setHoveredBookKey('');
}
if (sideBarBookKey === bookKey) {
toggleNotebook();
} else {
@@ -2,32 +2,83 @@
import clsx from 'clsx';
import * as React from 'react';
import { useEffect, Suspense, useRef } from 'react';
import { useEffect, Suspense, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { eventDispatcher } from '@/utils/event';
import { setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
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 { setLibrary } = useLibraryStore();
const { hoveredBookKey } = useReaderStore();
const { settings, setSettings } = useSettingsStore();
const { isSideBarVisible } = useSidebarStore();
const { getVisibleLibrary, setLibrary } = useLibraryStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [libraryLoaded, setLibraryLoaded] = useState(false);
const isInitiating = useRef(false);
const { updateAppTheme } = useThemeStore();
useTheme();
useTheme({ systemUIVisible: false, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
updateAppTheme('base-100');
const handleVisibilityChange = () => {
if (appService?.isMobileApp && !document.hidden) {
dismissSystemUI();
setSystemUIVisibility({ visible: false, darkMode: isDarkMode });
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isDarkMode]);
const handleKeyDown = (event: CustomEvent) => {
if (event.detail.keyName === 'Back') {
setSideBarVisible(false);
setNotebookVisible(false);
return true;
}
return false;
};
useEffect(() => {
if (!appService?.isAndroidApp) return;
if (isSideBarVisible || isNotebookVisible) {
acquireBackKeyInterception();
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
if (!isSideBarVisible && !isNotebookVisible) {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
return () => {
if (appService?.isAndroidApp) {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible, isNotebookVisible]);
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const initLibrary = async () => {
@@ -35,14 +86,27 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const settings = await appService.loadSettings();
setSettings(settings);
setLibrary(await appService.loadLibraryBooks());
setLibraryLoaded(true);
};
initLibrary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!appService?.isMobile) return;
const systemUIVisible = !!hoveredBookKey;
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
if (systemUIVisible) {
showSystemUI();
} else {
dismissSystemUI();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey]);
return (
getVisibleLibrary().length > 0 &&
libraryLoaded &&
settings.globalReadSettings && (
<div
className={clsx(
@@ -53,6 +117,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
{appService?.isAndroidApp && <UpdaterWindow />}
<Toast />
</Suspense>
</div>
@@ -12,7 +12,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { Book } from '@/types/book';
import { SystemSettings } from '@/types/settings';
import { parseOpenWithFiles } from '@/helpers/cli';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
import { isTauriAppPlatform } from '@/services/environment';
import { uniqueId } from '@/utils/misc';
@@ -135,7 +135,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
dismissBook(bookKey);
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
const openWithFiles = (await parseOpenWithFiles()) || [];
if (openWithFiles.length > 0) {
if (openWithFiles.length > 0 && !appService?.isMobile) {
tauriHandleClose();
} else {
saveSettingsAndGoToLibrary();
@@ -117,7 +117,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<MenuItem
label={_('Scrolled Mode')}
shortcut='Shift+J'
icon={isScrolledMode ? <MdCheck /> : undefined}
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
/>
@@ -132,7 +132,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
? _('Light Mode')
: _('Auto Mode')
}
icon={themeMode === 'dark' ? <BiMoon /> : themeMode === 'light' ? <BiSun /> : <TbSunMoon />}
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
</div>
@@ -53,6 +53,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const isTextSelected = useRef(false);
const isUpToShowPopup = useRef(false);
const isTouchstarted = useRef(false);
const selectionStartRef = useRef<number | null>(null);
const selectionAnchorRef = useRef<{ node: Node; offset: number } | null>(null);
const [selection, setSelection] = useState<TextSelection | null>();
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
@@ -120,6 +122,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, 40);
}, 0);
};
const handleSelectstart = () => {
selectionAnchorRef.current = null;
};
const handleSelectionchange = () => {
// Available on iOS, Android and Desktop, fired when the selection is changed
// Ideally the popup only shows when the selection is done,
@@ -130,6 +135,27 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const sel = doc.getSelection();
if (isValidSelection(sel)) {
if (osPlatform === 'android' && isTouchstarted.current) {
if (!selectionAnchorRef.current) {
const range = sel.getRangeAt(0);
selectionAnchorRef.current = {
node: range.startContainer,
offset: range.startOffset,
};
}
if (selectionAnchorRef.current) {
const currentRange = sel.getRangeAt(0);
if (
currentRange.startContainer !== selectionAnchorRef.current.node ||
currentRange.startOffset !== selectionAnchorRef.current.offset
) {
const newRange = doc.createRange();
newRange.setStart(selectionAnchorRef.current.node, selectionAnchorRef.current.offset);
newRange.setEnd(currentRange.endContainer, currentRange.endOffset);
sel.removeAllRanges();
sel.addRange(newRange);
}
}
makeSelection(sel, false);
}
} else if (!isUpToShowPopup.current) {
@@ -165,11 +191,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// Available on iOS, on Android fired after an additional touch event
isTouchstarted.current = false;
};
const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode
// This is a workaround for the issue #873
// TODO: support text selection across pages
if (
!viewSettings?.scrolled &&
isTextSelected.current &&
view?.renderer?.containerPosition &&
selectionStartRef.current
) {
view.renderer.containerPosition = selectionStartRef.current;
}
};
if (bookData.book?.format !== 'PDF') {
view?.renderer?.addEventListener('scroll', handleScroll);
detail.doc?.addEventListener('pointerup', handlePointerup);
detail.doc?.addEventListener('touchstart', handleTouchstart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchend);
detail.doc?.addEventListener('selectstart', handleSelectstart);
detail.doc?.addEventListener('selectionchange', handleSelectionchange);
// Disable the default context menu on mobile devices,
@@ -200,8 +241,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const lineHeightValue =
parseFloat(lineHeight) || viewSettings.lineHeight * viewSettings.defaultFontSize;
const fontSizeValue = parseFloat(fontSize) || viewSettings.defaultFontSize;
const strokeWidth = style === 'underline' ? 2 : 4;
const padding = (lineHeightValue - fontSizeValue - strokeWidth) / 2;
const strokeWidth = 2;
const padding = viewSettings.vertical
? (lineHeightValue - fontSizeValue - strokeWidth) / 2
: strokeWidth;
draw(Overlayer[style as keyof typeof Overlayer], { writingMode, color: hexColor, padding });
}
};
@@ -239,6 +282,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
isTextSelected.current = false;
};
useEffect(() => {
if (isTextSelected.current && !selectionStartRef.current) {
selectionStartRef.current = view?.renderer?.start || null;
} else if (!isTextSelected.current) {
selectionStartRef.current = null;
selectionAnchorRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTextSelected.current]);
useEffect(() => {
const handleSingleClick = (): boolean => {
if (isUpToShowPopup.current) {
@@ -3,7 +3,7 @@ import React from 'react';
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface DropdownProps {
family?: string;
@@ -24,33 +24,40 @@ const FontDropdown: React.FC<DropdownProps> = ({
}) => {
const _ = useTranslation();
const iconSize16 = useResponsiveSize(16);
const defaultIconSize = useDefaultIconSize();
const allOptions = [...options, ...(moreOptions ?? [])];
const selectedOption = allOptions.find((option) => option.option === selected) ?? allOptions[0]!;
return (
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
className='btn btn-sm flex items-center px-[10px] font-normal normal-case sm:px-[20px]'
onClick={(e) => e.currentTarget.focus()}
>
<span style={{ fontFamily: onGetFontFamily(selectedOption.option, family ?? '') }}>
{selectedOption.label}
</span>
<FiChevronUp size={iconSize16} />
<div className='flex items-center gap-x-1'>
<span
className='text-ellipsis'
style={{
fontFamily: onGetFontFamily(selectedOption.option, family ?? ''),
}}
>
{selectedOption.label}
</span>
<FiChevronUp size={iconSize16} />
</div>
</button>
<ul
tabIndex={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',
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] mt-4 shadow',
'!sm:px-2 right-[-32px] w-[46vw] !px-1 sm:right-0 sm:w-44',
moreOptions?.length ? '' : 'inline max-h-80 overflow-y-scroll',
)}
>
{options.map(({ option, label }) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-0'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selected === option && <MdCheck className='text-base-content' />}
<div className='flex w-full items-center overflow-hidden px-0 text-sm'>
<span style={{ minWidth: `${iconSize16}px` }}>
{selected === option && <MdCheck className='text-base-content' size={iconSize16} />}
</span>
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>
{label || option}
@@ -60,8 +67,8 @@ const FontDropdown: React.FC<DropdownProps> = ({
))}
{moreOptions && moreOptions.length > 0 && (
<li className='dropdown dropdown-left dropdown-top'>
<div className='flex items-center px-0'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
<div className='flex items-center px-0 text-sm'>
<span style={{ minWidth: `${iconSize16}px` }}>
<FiChevronLeft size={iconSize16} />
</span>
<span>{_('System Fonts')}</span>
@@ -69,15 +76,17 @@ const FontDropdown: React.FC<DropdownProps> = ({
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] overflow-y-scroll shadow',
'!mr-5 mb-[-46px] inline max-h-80 w-[200px] overflow-y-scroll',
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] shadow',
'!sm:px-2 !mr-4 mb-[-46px] inline max-h-80 w-[46vw] overflow-y-scroll !px-1 sm:w-[200px]',
)}
>
{moreOptions.map((option, index) => (
<li key={`${index}-${option.option}`} onClick={() => onSelect(option.option)}>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selected === option.option && <MdCheck className='text-base-content' />}
<div className='flex w-full items-center overflow-hidden px-0 text-sm'>
<span style={{ minWidth: `${iconSize16}px` }}>
{selected === option.option && (
<MdCheck className='text-base-content' size={iconSize16} />
)}
</span>
<span style={{ fontFamily: onGetFontFamily(option.option, family ?? '') }}>
{option.label || option.option}
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
import {
ANDROID_FONTS,
CJK_EXCLUDE_PATTENS,
CJK_FONTS_PATTENS,
CJK_NAMES_PATTENS,
CJK_SANS_SERIF_FONTS,
@@ -19,12 +20,21 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
import { getSysFontsList } from '@/utils/font';
import { getSysFontsList } from '@/utils/bridge';
import { isTauriAppPlatform } from '@/services/environment';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
const genCJKFontsList = (sysFonts: string[]) => {
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
.filter((font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font))
.filter((font) => !CJK_EXCLUDE_PATTENS.test(font))
.sort((a, b) => a.localeCompare(b));
};
const isSymbolicFontName = (font: string) =>
/emoji|icons|symbol|dingbats|ornaments|webdings|wingdings|miuiex/i.test(font);
interface FontFaceProps {
className?: string;
family: string;
@@ -51,7 +61,7 @@ const FontFace = ({
const _ = useTranslation();
return (
<div className={clsx('config-item', className)}>
<span className=''>{label}</span>
<span className='min-w-10'>{label}</span>
<FontDropdown
family={family}
options={options.map((option) => ({ option, label: _(option) }))}
@@ -66,7 +76,7 @@ const FontFace = ({
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { envConfig } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const view = getView(bookKey)!;
@@ -113,19 +123,28 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
const [CJKFonts] = useState<string[]>(() => {
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
.filter((font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font))
.sort((a, b) => a.localeCompare(b));
const [CJKFonts, setCJKFonts] = useState<string[]>(() => {
return genCJKFontsList(sysFonts);
});
useEffect(() => {
if (isTauriAppPlatform() && appService?.hasSysFontsList) {
getSysFontsList().then((fonts) => {
setSysFonts(fonts);
setCJKFonts((prev) => {
const newFonts = genCJKFontsList(sysFonts);
return prev.length !== newFonts.length ? newFonts : prev;
});
}, [sysFonts]);
useEffect(() => {
if (isTauriAppPlatform()) {
getSysFontsList().then((res) => {
if (res.error) {
console.error('Failed to get system fonts list:', res.error);
return;
}
const fonts = res.fonts.filter((font) => font && !isSymbolicFontName(font));
setSysFonts([...new Set(fonts)].sort((a, b) => a.localeCompare(b)));
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -5,6 +5,7 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useDeviceControlStore } from '@/store/deviceStore';
import { getStyles } from '@/utils/style';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { TRANSLATED_LANGS } from '@/services/constants';
@@ -16,12 +17,14 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
@@ -140,6 +143,18 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapClickArea]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
if (appService?.isMobileApp) {
if (volumeKeysToFlip) {
acquireVolumeKeyInterception();
} else {
releaseVolumeKeyInterception();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [volumeKeysToFlip]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -198,17 +213,28 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
{appService?.isMobileApp && (
<div className='config-item'>
<span className=''>{_('Volume Keys for Page Flip')}</span>
<input
type='checkbox'
className='toggle'
checked={volumeKeysToFlip}
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
/>
</div>
)}
<div className='config-item'>
<span className=''>{_('Disable Click-to-Flip')}</span>
<span className=''>{_('Clicks for Page Flip')}</span>
<input
type='checkbox'
className='toggle'
checked={isDisableClick}
checked={!isDisableClick}
onChange={() => setIsDisableClick(!isDisableClick)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Swap Click-to-Flip Area')}</span>
<span className=''>{_('Swap Clicks Area')}</span>
<input
type='checkbox'
className='toggle'
@@ -25,33 +25,48 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
const [lightBackgroundColor, setLightBackgroundColor] = useState(
customTheme?.colors.light.bg || template.light.bg,
);
const [lightPrimaryColor, setLightPrimaryColor] = useState(
customTheme?.colors.light.primary || template.light.primary,
);
const [darkTextColor, setDarkTextColor] = useState(
customTheme?.colors.dark.fg || template.dark.fg,
);
const [darkBackgroundColor, setDarkBackgroundColor] = useState(
customTheme?.colors.dark.bg || template.dark.bg,
);
const [darkPrimaryColor, setDarkPrimaryColor] = useState(
customTheme?.colors.dark.primary || template.dark.primary,
);
const [themeName, setThemeName] = useState(customTheme?.label || _('Custom'));
const ThemePreview: React.FC<{
textColor: string;
backgroundColor: string;
primaryColor: string;
label: string;
}> = ({ textColor, backgroundColor, label }) => (
}> = ({ textColor, backgroundColor, primaryColor, label }) => (
<div className='mb-2 mt-4'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div
className='border-base-300 overflow-hidden rounded border p-3'
className='border-base-300 overflow-hidden rounded border p-2'
style={{
backgroundColor: backgroundColor,
color: textColor,
}}
>
<p className='mb-2 whitespace-pre-line text-sm'>
<p className='mb-2 whitespace-pre-line text-xs'>
{_(
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare",
)}
<p
className='mt-4 cursor-pointer italic'
style={{
color: primaryColor,
}}
>
{_("(from 'As You Like It', Act II)")}
</p>
</p>
</div>
</div>
@@ -65,12 +80,12 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
light: {
fg: lightTextColor,
bg: lightBackgroundColor,
primary: '#3b82f6',
primary: lightPrimaryColor,
},
dark: {
fg: darkTextColor,
bg: darkBackgroundColor,
primary: '#60a5fa',
primary: darkPrimaryColor,
},
},
};
@@ -89,17 +104,16 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
{_('Save')}
</button>
<button
className={clsx(
'btn btn-ghost btn-sm px-2',
!settings.globalReadSettings.customThemes.find(
(theme) => theme.name === md5Fingerprint(themeName),
) && 'btn-disabled',
)}
onClick={() => onDelete(getCustomTheme())}
>
{_('Delete')}
</button>
{settings.globalReadSettings.customThemes.find(
(theme) => theme.name === md5Fingerprint(themeName),
) && (
<button
className={clsx('btn btn-ghost btn-sm px-2')}
onClick={() => onDelete(getCustomTheme())}
>
{_('Delete')}
</button>
)}
<button className='btn btn-ghost btn-sm px-2' onClick={onCancel}>
{_('Cancel')}
@@ -129,9 +143,16 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
onChange={setLightBackgroundColor}
/>
<ColorInput
label={_('Link Color')}
value={lightPrimaryColor}
onChange={setLightPrimaryColor}
/>
<ThemePreview
textColor={lightTextColor}
backgroundColor={lightBackgroundColor}
primaryColor={lightPrimaryColor}
label={_('Preview')}
/>
</div>
@@ -147,9 +168,16 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
onChange={setDarkBackgroundColor}
/>
<ColorInput
label={_('Link Color')}
value={darkPrimaryColor}
onChange={setDarkPrimaryColor}
/>
<ThemePreview
textColor={darkTextColor}
backgroundColor={darkBackgroundColor}
primaryColor={darkPrimaryColor}
label={_('Preview')}
/>
</div>
@@ -62,13 +62,13 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
.map((book) => (
<MenuItem
key={book.hash}
icon={
Icon={
<Image
src={book.coverImageUrl!}
alt={book.title}
width={56}
height={80}
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
className='aspect-auto max-h-8 max-w-4 rounded-sm shadow-md'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
@@ -38,9 +38,10 @@ const TTSControl = () => {
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 maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
@@ -98,7 +99,7 @@ const TTSControl = () => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !viewSettings || !bookData) return;
if (!view || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
@@ -107,6 +108,7 @@ const TTSControl = () => {
return;
}
const primaryLang = bookData.book.primaryLanguage;
setBookKey(bookKey);
if (ttsControllerRef.current) {
@@ -127,9 +129,13 @@ const TTSControl = () => {
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
if (ssml) {
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
let lang = parseSSMLLang(ssml) || 'en';
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
if (lang === 'en' && primaryLang && primaryLang !== 'en') {
lang = primaryLang.split('-')[0]!;
}
setIsPlaying(true);
setTtsLang(lang);
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
@@ -6,7 +6,7 @@ import useBooksManager from './useBooksManager';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { getStyles } from '@/utils/style';
import { tauriQuitApp } from '@/utils/window';
import { tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
@@ -84,6 +84,12 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
window.location.reload();
};
const toggleFullscreen = async () => {
if (isTauriAppPlatform()) {
await tauriHandleToggleFullScreen();
}
};
const quitApp = async () => {
// on web platform use browser's default shortcut to close the tab
if (isTauriAppPlatform()) {
@@ -136,6 +142,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onOpenFontLayoutSettings: () => setFontLayoutSettingsDialogOpen(true),
onToggleSearchBar: showSearchBar,
onReloadPage: reloadPage,
onToggleFullscreen: toggleFullscreen,
onQuitApp: quitApp,
onGoLeft: goLeft,
onGoRight: goRight,
@@ -1,118 +1,16 @@
import { useEffect } from 'react';
import { FoliateView } from '@/types/view';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { tauriGetWindowLogicalPosition } from '@/utils/window';
export const useClickEvent = (
bookKey: string,
viewRef: React.MutableRefObject<FoliateView | null>,
containerRef: React.RefObject<HTMLDivElement>,
) => {
const { appService } = useEnv();
const { getViewSettings } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const handleTurnPage = async (
msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
if (msg instanceof MessageEvent) {
if (msg.data && msg.data.bookKey === bookKey) {
const viewSettings = getViewSettings(bookKey)!;
if (msg.data.type === 'iframe-single-click') {
const viewElement = containerRef.current;
if (viewElement) {
const { screenX } = msg.data;
const viewRect = viewElement.getBoundingClientRect();
let windowStartX;
// Currently for tauri APP the window.screenX is always 0
if (isTauriAppPlatform()) {
if (appService?.isMobile) {
windowStartX = 0;
} else {
const windowPosition = (await tauriGetWindowLogicalPosition()) as {
x: number;
y: number;
};
windowStartX = windowPosition.x;
}
} else {
windowStartX = window.screenX;
}
const viewStartX = windowStartX + viewRect.left;
const viewCenterX = viewStartX + viewRect.width / 2;
const consumed = eventDispatcher.dispatchSync('iframe-single-click');
if (!consumed) {
const centerStartX = viewStartX + viewRect.width * 0.375;
const centerEndX = viewStartX + viewRect.width * 0.625;
if (
viewSettings.disableClick! ||
(screenX >= centerStartX && screenX <= centerEndX)
) {
// toggle visibility of the header bar and the footer bar
setHoveredBookKey(hoveredBookKey ? null : bookKey);
} else {
if (hoveredBookKey) {
setHoveredBookKey(null);
return;
}
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
if (viewSettings.swapClickArea) {
viewRef.current?.goLeft();
} else {
viewRef.current?.goRight();
}
} else if (!viewSettings.disableClick! && screenX < viewCenterX) {
if (viewSettings.swapClickArea) {
viewRef.current?.goRight();
} else {
viewRef.current?.goLeft();
}
}
}
}
}
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
// The wheel event is handled by the iframe itself in scrolled mode.
const { deltaY } = msg.data;
if (deltaY > 0) {
viewRef.current?.next(1);
} else if (deltaY < 0) {
viewRef.current?.prev(1);
}
} else if (msg.data.type === 'iframe-mouseup') {
if (msg.data.button === 3) {
viewRef.current?.history.back();
} else if (msg.data.button === 4) {
viewRef.current?.history.forward();
}
}
}
} else {
const { clientX } = msg;
const width = window.innerWidth;
const leftThreshold = width * 0.5;
const rightThreshold = width * 0.5;
if (clientX < leftThreshold) {
viewRef.current?.goLeft();
} else if (clientX > rightThreshold) {
viewRef.current?.goRight();
}
}
};
export const useClickEvent = (bookKey: string, handlePageFlip: (msg: MessageEvent) => void) => {
const { hoveredBookKey } = useReaderStore();
useEffect(() => {
window.addEventListener('message', handleTurnPage);
window.addEventListener('message', handlePageFlip);
return () => {
window.removeEventListener('message', handleTurnPage);
window.removeEventListener('message', handlePageFlip);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef]);
return {
handleTurnPage,
};
}, [bookKey, hoveredBookKey, handlePageFlip]);
};
interface IframeTouch {
@@ -213,5 +111,5 @@ export const useTouchEvent = (
window.removeEventListener('message', handleTouch);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef]);
}, [hoveredBookKey, viewRef.current]);
};
@@ -0,0 +1,139 @@
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { FoliateView } from '@/types/view';
import { useReaderStore } from '@/store/readerStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { tauriGetWindowLogicalPosition } from '@/utils/window';
export const usePageFlip = (
bookKey: string,
viewRef: React.MutableRefObject<FoliateView | null>,
containerRef: React.RefObject<HTMLDivElement>,
) => {
const { appService } = useEnv();
const { getViewSettings } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const handlePageFlip = async (
msg: MessageEvent | CustomEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
if (msg instanceof MessageEvent) {
if (msg.data && msg.data.bookKey === bookKey) {
const viewSettings = getViewSettings(bookKey)!;
if (msg.data.type === 'iframe-single-click') {
const viewElement = containerRef.current;
if (viewElement) {
const { screenX } = msg.data;
const viewRect = viewElement.getBoundingClientRect();
let windowStartX;
// Currently for tauri APP the window.screenX is always 0
if (isTauriAppPlatform()) {
if (appService?.isMobile) {
windowStartX = 0;
} else {
const windowPosition = (await tauriGetWindowLogicalPosition()) as {
x: number;
y: number;
};
windowStartX = windowPosition.x;
}
} else {
windowStartX = window.screenX;
}
const viewStartX = windowStartX + viewRect.left;
const viewCenterX = viewStartX + viewRect.width / 2;
const consumed = eventDispatcher.dispatchSync('iframe-single-click');
if (!consumed) {
const centerStartX = viewStartX + viewRect.width * 0.375;
const centerEndX = viewStartX + viewRect.width * 0.625;
if (
viewSettings.disableClick! ||
(screenX >= centerStartX && screenX <= centerEndX)
) {
// toggle visibility of the header bar and the footer bar
setHoveredBookKey(hoveredBookKey ? null : bookKey);
} else {
if (hoveredBookKey) {
setHoveredBookKey(null);
return;
}
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
if (viewSettings.swapClickArea) {
viewRef.current?.goLeft();
} else {
viewRef.current?.goRight();
}
} else if (!viewSettings.disableClick! && screenX < viewCenterX) {
if (viewSettings.swapClickArea) {
viewRef.current?.goRight();
} else {
viewRef.current?.goLeft();
}
}
}
}
}
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
// The wheel event is handled by the iframe itself in scrolled mode.
const { deltaY } = msg.data;
if (deltaY > 0) {
viewRef.current?.next(1);
} else if (deltaY < 0) {
viewRef.current?.prev(1);
}
} else if (msg.data.type === 'iframe-mouseup') {
if (msg.data.button === 3) {
viewRef.current?.history.back();
} else if (msg.data.button === 4) {
viewRef.current?.history.forward();
}
}
}
} else if (msg instanceof CustomEvent) {
const { keyName } = msg.detail;
const viewSettings = getViewSettings(bookKey);
if (viewSettings?.volumeKeysToFlip) {
setHoveredBookKey('');
if (keyName === 'VolumeUp') {
viewRef.current?.goLeft();
} else if (keyName === 'VolumeDown') {
viewRef.current?.goRight();
}
}
} else {
const { clientX } = msg;
const width = window.innerWidth;
const leftThreshold = width * 0.5;
const rightThreshold = width * 0.5;
if (clientX < leftThreshold) {
viewRef.current?.goLeft();
} else if (clientX > rightThreshold) {
viewRef.current?.goRight();
}
}
};
useEffect(() => {
if (!appService?.isMobileApp) return;
const viewSettings = getViewSettings(bookKey);
if (viewSettings?.volumeKeysToFlip) {
acquireVolumeKeyInterception();
} else {
releaseVolumeKeyInterception();
}
eventDispatcher.on('native-key-down', handlePageFlip);
return () => {
releaseVolumeKeyInterception();
eventDispatcher.off('native-key-down', handlePageFlip);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
handlePageFlip,
};
};
+4 -3
View File
@@ -1,22 +1,23 @@
'use client';
import { useEffect } from 'react';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useSettingsStore } from '@/store/settingsStore';
import { checkForAppUpdates } from '@/helpers/updater';
import Reader from './components/Reader';
export default function Page() {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
useOpenWithBooks();
useEffect(() => {
const doCheckAppUpdates = async () => {
if (hasUpdater() && settings.autoCheckUpdates) {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
}
};
+6 -231
View File
@@ -1,240 +1,13 @@
'use client';
import clsx from 'clsx';
import semver from 'semver';
import Image from 'next/image';
import { Suspense, useEffect, useState } from 'react';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { fetch } from '@tauri-apps/plugin-http';
import { useTranslation } from '@/hooks/useTranslation';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { UpdaterContent } from '@/components/UpdaterWindow';
import { useTheme } from '@/hooks/useTheme';
import packageJson from '../../../package.json';
import Spinner from '@/components/Spinner';
const CHANGELOG_URL =
'https://github.com/readest/readest/releases/latest/download/release-notes.json';
interface ReleaseNotes {
releases: Record<
string,
{
date: string;
notes: string[];
}
>;
}
interface Changelog {
version: string;
date: string;
notes: string[];
}
const UpdaterDialog = () => {
const _ = useTranslation();
const searchParams = useSearchParams();
const currentVersion = packageJson.version;
const version = searchParams?.get('version') || '';
const [update, setUpdate] = useState<Update | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [newVersion, setNewVersion] = useState(version);
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
const [progress, setProgress] = useState<number | null>(null);
const [contentLength, setContentLength] = useState<number | null>(null);
const [downloaded, setDownloaded] = useState<number | null>(null);
useEffect(() => {
const fetchChangelogs = async (
fromVersion: string,
toVersion: string,
): Promise<Changelog[]> => {
try {
const res = await fetch(CHANGELOG_URL);
const data: ReleaseNotes = await res.json();
const releases = data.releases;
const entries = Object.entries(releases)
.filter(([ver]) => semver.gt(ver, fromVersion) && semver.lte(ver, toVersion))
.sort(([a], [b]) => semver.rcompare(a, b))
.map(([version, info]) => ({
version,
date: new Date(info.date).toDateString(),
notes: info.notes,
}));
return entries;
} catch (error) {
console.error('Failed to fetch changelog:', error);
return [];
}
};
const parseNumberedList = (input: string): string[] => {
return input
.split(/(?:^|\s)\d+\.\s/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
};
const checkForUpdates = async () => {
const update = await check();
setUpdate(update);
if (update) {
setNewVersion(update.version);
const changelogs = await fetchChangelogs(currentVersion, update.version);
if (changelogs.length > 0) {
setChangelogs(changelogs);
} else {
setChangelogs([
{
version: update.version,
date: new Date(update.date!).toDateString(),
notes: parseNumberedList(update.body ?? ''),
},
]);
}
}
};
checkForUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const UpdaterPage = () => {
useTheme();
useEffect(() => {
setIsMounted(true);
}, []);
const handleDownloadInstall = async () => {
if (!update) {
return;
}
console.log(`found update ${update.version} from ${update.date} with notes ${update.body}`);
let downloaded = 0;
let contentLength = 0;
let lastLogged = 0;
setProgress(0);
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength!;
setContentLength(contentLength);
console.log(`started downloading ${event.data.contentLength} bytes`);
break;
case 'Progress':
downloaded += event.data.chunkLength;
setDownloaded(downloaded);
const percent = Math.floor((downloaded / contentLength) * 100);
setProgress(percent);
if (downloaded - lastLogged >= 1 * 1024 * 1024) {
console.log(`downloaded ${downloaded} bytes from ${contentLength}`);
lastLogged = downloaded;
}
break;
case 'Finished':
console.log('download finished');
setProgress(100);
break;
}
});
console.log('update installed');
await relaunch();
};
if (!isMounted) {
return null;
}
return (
<div className='bg-base-300 flex min-h-screen items-center justify-center'>
<div className='card bg-base-200 w-full max-w-2xl !rounded-none shadow-xl'>
<div className='card-body'>
<div className='flex items-start gap-8'>
<div className='flex flex-col items-center justify-center gap-4'>
<div className='bg-base-200 flex items-center justify-center rounded-2xl shadow-md'>
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
</div>
</div>
<div className='text-base-content flex-1 text-sm'>
<h2 className='mb-2 font-bold'>{_('A new version of Readest is Available!')}</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
currentVersion,
})}
</p>
<p className='mb-2'>{_('Download and install now?')}</p>
<div className='flex w-full flex-row items-center justify-end gap-4'>
{progress !== null && (
<div className='flex flex-grow flex-col'>
<progress
className='progress my-1 h-4 w-full'
value={progress}
max='100'
></progress>
<p className='text-base-content/75 flex items-center justify-center text-sm'>
{progress < 100
? _('Downloading {{downloaded}} of {{contentLength}}', {
downloaded: downloaded
? `${Math.floor(downloaded / 1024 / 1024)} MB`
: '0 MB',
contentLength: contentLength
? `${Math.floor(contentLength / 1024 / 1024)} MB`
: '0 MB',
})
: _('Download finished')}
</p>
</div>
)}
<div className='card-actions'>
<button
className={clsx(
'btn btn-warning text-base-100 px-6 font-bold',
!update && 'btn-disabled',
)}
onClick={handleDownloadInstall}
>
{_('DOWNLOAD & INSTALL')}
</button>
</div>
</div>
</div>
</div>
</div>
<div className='text-base-content px-8 text-sm'>
<h3 className='mb-2 font-bold'>{_('Changelog:')}</h3>
<div className='bg-base-300 mb-4 rounded-lg px-4 pb-2 pt-4'>
{changelogs.length > 0 ? (
changelogs.map((entry: Changelog) => (
<div key={entry.version} className='mb-4'>
<h4 className='mb-2 font-bold'>
{entry.version} ({entry.date})
</h4>
<ul className='list-disc space-y-1 pl-6 text-sm'>
{entry.notes.map((note: string, i: number) => (
<li key={i}>{note}</li>
))}
</ul>
</div>
))
) : (
<div className='flex h-56 w-full flex-col gap-4'>
<div className='skeleton h-4 w-28'></div>
<div className='skeleton h-4 w-full'></div>
<div className='skeleton h-4 w-full'></div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
const UpdaterPage = () => {
return (
<Suspense
fallback={
@@ -243,7 +16,9 @@ const UpdaterPage = () => {
</div>
}
>
<UpdaterDialog />
<div className='px-12 py-4'>
<UpdaterContent />
</div>
</Suspense>
);
};
+1 -1
View File
@@ -34,7 +34,7 @@ const ProfilePage = () => {
const headerRef = useRef<HTMLDivElement>(null);
useTheme();
useTheme({ systemUIVisible: false });
useEffect(() => {
if (!user || !token) return;
+78 -73
View File
@@ -3,17 +3,18 @@ import Image from 'next/image';
import packageJson from '../../package.json';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { parseWebViewVersion } from '@/utils/ua';
import Dialog from './Dialog';
import Link from './Link';
export const setAboutDialogVisible = (visible: boolean) => {
const dialog = document.getElementById('about_window');
if (visible) {
(dialog as HTMLDialogElement)?.showModal();
} else {
(dialog as HTMLDialogElement)?.close();
if (dialog) {
const event = new CustomEvent('setDialogVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
@@ -22,90 +23,94 @@ export const AboutWindow = () => {
const { appService } = useEnv();
const [isUpdated, setIsUpdated] = useState(false);
const [browserInfo, setBrowserInfo] = useState('');
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setBrowserInfo(parseWebViewVersion(appService));
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
};
const el = document.getElementById('about_window');
if (el) {
el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener);
}
return () => {
if (el) {
el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCheckUpdate = async () => {
const update = await checkForAppUpdates(_, false);
if (!update) {
if (update) {
setIsOpen(false);
} else {
setIsUpdated(true);
}
};
return (
<>
<Dialog
id='about_window'
isOpen={false}
title={_('About Readest')}
onClose={() => setAboutDialogVisible(false)}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center px-8'>
<div className='mb-3'>
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
</div>
<div className='flex select-text flex-col items-center'>
<h2 className='text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-sm'>
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
</p>
</div>
<div className='h-5'>
{hasUpdater() && !isUpdated && (
<span
className='badge badge-primary mt-2 cursor-pointer'
onClick={handleCheckUpdate}
>
{_('Check Update')}
</span>
)}
{isUpdated && (
<p className='text-neutral-content mt-2 text-xs'>
{_('Already the latest version')}
</p>
)}
</div>
<Dialog
id='about_window'
isOpen={isOpen}
title={_('About Readest')}
onClose={() => setIsOpen(false)}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center px-8'>
<div className='mb-3'>
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
</div>
<div className='divider py-12 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<a
href='https://www.gnu.org/licenses/agpl-3.0.html'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
GNU Affero General Public License v3.0
</a>
. You are free to use, modify, and distribute this software under the terms of the
AGPL v3 license. Please see the license for more details.
</p>
<p className='text-neutral-content my-2 text-xs'>
Source code is available at{' '}
<a
href='https://github.com/readest/readest'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
GitHub
</a>
.
<div className='flex select-text flex-col items-center'>
<h2 className='text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-center text-sm'>
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
</p>
</div>
<div className='h-5'>
{appService?.hasUpdater && !isUpdated && (
<span className='badge badge-primary mt-2 cursor-pointer' onClick={handleCheckUpdate}>
{_('Check Update')}
</span>
)}
{isUpdated && (
<p className='text-neutral-content mt-2 text-xs'>{_('Already the latest version')}</p>
)}
</div>
</div>
</Dialog>
</>
<div className='divider py-12 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<Link
href='https://www.gnu.org/licenses/agpl-3.0.html'
className='text-blue-500 underline'
>
GNU Affero General Public License v3.0
</Link>
. You are free to use, modify, and distribute this software under the terms of the AGPL
v3 license. Please see the license for more details.
</p>
<p className='text-neutral-content my-2 text-xs'>
Source code is available at{' '}
<Link href='https://github.com/readest/readest' className='text-blue-500 underline'>
GitHub
</Link>
.
</p>
</div>
</div>
</Dialog>
);
};
+5 -4
View File
@@ -17,9 +17,10 @@ const Alert: React.FC<{
'alert flex items-center justify-between',
'bg-base-300 rounded-lg border-none p-4 shadow-2xl',
'w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] lg:max-w-[40vw] xl:max-w-[40vw]',
'min-w-[70vw] flex-col sm:min-w-[40vw] sm:flex-row',
)}
>
<div className='flex items-center space-x-2'>
<div className='flex items-center space-x-2 self-start'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
@@ -34,12 +35,12 @@ const Alert: React.FC<{
></path>
</svg>
<div className=''>
<h3 className='font-sm text-base'>{title}</h3>
<h3 className='font-sm text-start text-base sm:text-center'>{title}</h3>
<div className='text-xs'>{message}</div>
</div>
</div>
<div className='flex flex-wrap items-center justify-center gap-2'>
<button className='btn btn-sm' onClick={onCancel}>
<div className='flex flex-wrap items-center gap-2 self-end sm:max-w-[20vw]'>
<button className='btn btn-sm btn-neutral' onClick={onCancel}>
{_('Cancel')}
</button>
<button className='btn btn-sm btn-warning' onClick={onConfirm}>
@@ -1,13 +1,13 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { MdDelete, MdCloudDownload, MdCloudUpload } from 'react-icons/md';
import { Book } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import {
formatAuthors,
formatDate,
@@ -24,16 +24,25 @@ interface BookDetailModalProps {
book: Book;
isOpen: boolean;
onClose: () => void;
handleBookDownload?: (book: Book) => void;
handleBookUpload?: (book: Book) => void;
handleBookDelete?: (book: Book) => void;
}
const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
const BookDetailModal = ({
book,
isOpen,
onClose,
handleBookDownload,
handleBookUpload,
handleBookDelete,
}: BookDetailModalProps) => {
const _ = useTranslation();
const [loading, setLoading] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
const { envConfig, appService } = useEnv();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
useEffect(() => {
const loadingTimeout = setTimeout(() => setLoading(true), 300);
@@ -61,10 +70,25 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
};
const confirmDelete = async () => {
await appService?.deleteBook(book, !!book.uploadedAt);
await updateBook(envConfig, book);
handleClose();
setShowDeleteAlert(false);
if (handleBookDelete) {
handleBookDelete(book);
}
};
const handleRedownload = async () => {
handleClose();
if (handleBookDownload) {
handleBookDownload(book);
}
};
const handleReupload = async () => {
handleClose();
if (handleBookUpload) {
handleBookUpload(book);
}
};
if (!bookMeta)
@@ -83,13 +107,13 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
isOpen={isOpen}
onClose={handleClose}
bgClassName='sm:bg-black/50'
boxClassName='sm:min-w-[480px] sm:h-auto'
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
contentClassName='!px-6 !py-2'
>
<div className='flex w-full select-text items-center justify-center'>
<div className='relative w-full rounded-lg'>
<div className='mb-10 flex h-40 items-start'>
<div className='book-cover relative mr-10 aspect-[28/41] h-40 items-end shadow-lg'>
<div className='mb-6 me-4 flex h-32 items-start'>
<div className='book-cover relative mr-10 aspect-[28/41] h-32 items-end shadow-lg'>
<Image
src={book.coverImageUrl!}
alt={formatTitle(book.title)}
@@ -112,45 +136,35 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
</div>
</div>
<div className='title-author flex h-40 flex-col justify-between'>
<div className='title-author flex h-32 flex-col justify-between'>
<div>
<p className='text-base-content mb-2 line-clamp-2 break-all text-2xl font-bold'>
<p className='text-base-content mb-2 line-clamp-2 break-all text-lg font-bold'>
{formatTitle(book.title) || _('Untitled')}
</p>
<p className='text-neutral-content line-clamp-1'>
{formatAuthors(book.author, bookMeta.language) || _('Unknown')}
{formatAuthors(book.author, book.primaryLanguage) || _('Unknown')}
</p>
</div>
{window.innerWidth >= 400 && (
<div className='flex flex-wrap items-center gap-x-4 gap-y-2 py-2'>
<button
className='btn rounded-xl bg-red-600 px-4 text-white hover:bg-red-700'
onClick={handleDelete}
>
{_('Delete')}
<div className='flex flex-wrap items-center gap-x-4'>
{handleBookDelete && (
<button onClick={handleDelete}>
<MdDelete className='fill-red-500' />
</button>
<button className='btn btn-disabled bg-primary/25 hover:bg-primary/85 rounded-xl px-4 text-white'>
{_('More Info')}
)}
{book.uploadedAt && handleBookDownload && (
<button onClick={handleRedownload}>
<MdCloudDownload className='fill-base-content' />
</button>
</div>
)}
)}
{book.downloadedAt && handleBookUpload && (
<button onClick={handleReupload}>
<MdCloudUpload className='fill-base-content' />
</button>
)}
</div>
</div>
</div>
{window.innerWidth < 400 && (
<div className='flex flex-wrap items-center gap-x-4 gap-y-2 py-2'>
<button
className='btn rounded bg-red-600 text-white hover:bg-red-700'
onClick={handleDelete}
>
{_('Delete')}
</button>
<button className='btn btn-disabled bg-primary/25 hover:bg-primary/85 rounded px-4 text-white'>
{_('More Info')}
</button>
</div>
)}
<div className='text-base-content my-4'>
<div className='mb-4 grid grid-cols-2 gap-4 sm:grid-cols-3'>
<div className='overflow-hidden'>
@@ -167,11 +181,12 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Updated:')}</span>
<p className='text-neutral-content text-sm'>
{formatDate(book.lastUpdated) || ''}
</p>
<p className='text-neutral-content text-sm'>{formatDate(book.updatedAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Added:')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.createdAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Language:')}</span>
<p className='text-neutral-content text-sm'>
@@ -190,6 +205,18 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
{formatSubject(bookMeta.subject) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Format:')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{book.format || _('Unknown')}
</p>
</div>
</div>
<div>
<span className='font-bold'>{_('Description:')}</span>
<p className='text-neutral-content text-sm'>
{bookMeta.description || _('No description available')}
</p>
</div>
</div>
</div>
+21 -3
View File
@@ -3,9 +3,11 @@ import React, { ReactNode, useEffect, useState } from 'react';
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { getDirFromUILanguage } from '@/utils/rtl';
import { eventDispatcher } from '@/utils/event';
const VELOCITY_THRESHOLD = 0.5;
const SNAP_THRESHOLD = 0.2;
@@ -38,24 +40,40 @@ const Dialog: React.FC<DialogProps> = ({
onClose,
}) => {
const { appService } = useEnv();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [isFullHeightInMobile, setIsFullHeightInMobile] = React.useState(!snapHeight);
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const handleKeyDown = (event: KeyboardEvent | CustomEvent) => {
if (event instanceof CustomEvent) {
if (event.detail.keyName === 'Back') {
onClose();
return true;
}
} else if (event.key === 'Escape') {
onClose();
}
return false;
};
useEffect(() => {
if (!isOpen) return;
window.addEventListener('keydown', handleKeyDown);
if (appService?.isAndroidApp) {
acquireBackKeyInterception();
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
if (appService?.isAndroidApp) {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [isOpen]);
const handleDragMove = (data: { clientY: number; deltaY: number }) => {
if (!isMobile) return;
+23
View File
@@ -0,0 +1,23 @@
import { isTauriAppPlatform } from '@/services/environment';
import { openUrl } from '@tauri-apps/plugin-opener';
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
}
const Link: React.FC<LinkProps> = ({ href, children, ...props }) => {
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
if (isTauriAppPlatform()) {
e.preventDefault();
await openUrl(href);
}
};
return (
<a href={href} target='_blank' rel='noopener noreferrer' onClick={handleClick} {...props}>
{children}
</a>
);
};
export default Link;
+18 -6
View File
@@ -1,40 +1,52 @@
import clsx from 'clsx';
import React from 'react';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { IconType } from 'react-icons';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface MenuItemProps {
label: string;
buttonClass?: string;
labelClass?: string;
shortcut?: string;
disabled?: boolean;
noIcon?: boolean;
icon?: React.ReactNode;
Icon?: React.ReactNode | IconType;
children?: React.ReactNode;
onClick?: () => void;
}
const MenuItem: React.FC<MenuItemProps> = ({
label,
buttonClass,
labelClass,
shortcut,
disabled,
noIcon = false,
icon,
Icon,
children,
onClick,
}) => {
const iconSize = useDefaultIconSize();
const iconSize = useResponsiveSize(16);
const menuButton = (
<button
className={clsx(
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-2',
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-1',
disabled && 'btn-disabled text-gray-400',
buttonClass,
)}
onClick={onClick}
disabled={disabled}
>
<div className='flex min-w-0 items-center'>
{!noIcon && <span style={{ minWidth: `${iconSize}px` }}>{icon}</span>}
{!noIcon && (
<span style={{ minWidth: `${iconSize}px` }}>
{typeof Icon === 'function' ? (
<Icon className='text-base-content' size={iconSize} />
) : (
Icon
)}
</span>
)}
<span
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm', labelClass)}
style={{ minWidth: 0 }}
+3 -1
View File
@@ -40,7 +40,9 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
></div>
)}
<div className={clsx('relative flex items-center justify-between p-2', className)}>
<div
className={clsx('relative flex items-center justify-between gap-4 p-2', className)}
>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
<span className='truncate'>{quota.name}</span>
</div>
+10 -7
View File
@@ -17,10 +17,10 @@ export const Toast = () => {
error: 'toast-error toast-top toast-end',
};
const alertClassMap = {
info: 'alert-primary',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error',
info: 'alert-primary border-base-300',
success: 'alert-success border-0',
warning: 'alert-warning border-0',
error: 'alert-error border-0',
};
useEffect(() => {
@@ -58,14 +58,17 @@ export const Toast = () => {
>
<div
className={clsx(
'alert flex max-w-80 items-center justify-center border-0',
'alert flex items-center justify-center',
alertClassMap[toastType.current],
)}
>
<span
className={clsx(
'max-h-[50vh] min-w-32 max-w-80',
'overflow-y-auto whitespace-normal break-words text-center',
'max-h-[50vh] min-w-32',
'overflow-y-auto text-center',
toastType.current === 'info'
? 'max-w-[80vw]'
: 'max-w-80 whitespace-normal break-words',
messageClass.current,
)}
>
@@ -0,0 +1,382 @@
import clsx from 'clsx';
import semver from 'semver';
import Image from 'next/image';
import { useEnv } from '@/context/EnvContext';
import { useEffect, useState } from 'react';
import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { fetch } from '@tauri-apps/plugin-http';
import { useTranslation } from '@/hooks/useTranslation';
import { useSearchParams } from 'next/navigation';
import { tauriDownload } from '@/utils/transfer';
import { installPackage } from '@/utils/bridge';
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
import packageJson from '../../package.json';
import Dialog from '@/components/Dialog';
interface ReleaseNotes {
releases: Record<
string,
{
date: string;
notes: string[];
}
>;
}
interface Changelog {
version: string;
date: string;
notes: string[];
}
type DownloadEvent =
| {
event: 'Started';
data: {
contentLength?: number;
};
}
| {
event: 'Progress';
data: {
chunkLength: number;
};
}
| {
event: 'Finished';
};
interface GenericUpdate {
currentVersion: string;
version: string;
date?: string;
body?: string;
downloadAndInstall?(onEvent?: (progress: DownloadEvent) => void): Promise<void>;
}
export const UpdaterContent = ({ version }: { version?: string }) => {
const _ = useTranslation();
const { appService } = useEnv();
const searchParams = useSearchParams();
const currentVersion = packageJson.version;
const resolvedVersion = version ?? searchParams?.get('version') ?? '';
const [update, setUpdate] = useState<GenericUpdate | Update | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [newVersion, setNewVersion] = useState(resolvedVersion);
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
const [progress, setProgress] = useState<number | null>(null);
const [contentLength, setContentLength] = useState<number | null>(null);
const [isDownloading, setIsDownloading] = useState(false);
const [downloaded, setDownloaded] = useState<number | null>(null);
useEffect(() => {
const checkDesktopUpdate = async () => {
const update = await check();
if (update) {
setUpdate(update);
}
};
const checkAndroidUpdate = async () => {
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
if (semver.gt(data.version, packageJson.version)) {
const OS_ARCH = osArch();
const platformKey = OS_ARCH === 'aarch64' ? 'android-arm64' : 'android-universal';
const arch = OS_ARCH === 'aarch64' ? 'arm64' : 'universal';
const downloadUrl = data.platforms[platformKey]?.url as string;
const cachePrefix = appService?.fs.getPrefix('Cache');
const apkFilePath = `${cachePrefix}/Readest_${data.version}_${arch}.apk`;
setUpdate({
currentVersion: packageJson.version,
version: data.version,
date: data.date,
body: data.notes,
downloadAndInstall: async (onEvent) => {
await new Promise<void>(async (resolve, reject) => {
let downloaded = 0;
let total = 0;
await tauriDownload(downloadUrl, apkFilePath, (progress) => {
if (!onEvent) return;
if (!total && progress.total) {
total = progress.total;
onEvent({
event: 'Started',
data: { contentLength: total },
});
} else if (downloaded > 0 && progress.progress === progress.total) {
console.log('APK downloaded to', apkFilePath);
onEvent?.({ event: 'Finished' });
setTimeout(() => {
resolve();
}, 1000);
}
onEvent({
event: 'Progress',
data: { chunkLength: progress.progress - downloaded },
});
downloaded = progress.progress;
}).catch((error) => {
console.error('Download failed:', error);
reject(error);
});
});
const res = await installPackage({
path: apkFilePath,
});
if (res.success) {
console.log('APK installed successfully');
} else {
console.error('Failed to install APK:', res.error);
}
},
} as GenericUpdate);
}
};
const checkForUpdates = async () => {
const OS_TYPE = osType();
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
checkDesktopUpdate();
} else if (OS_TYPE === 'android') {
checkAndroidUpdate();
}
};
checkForUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const fetchChangelogs = async (
fromVersion: string,
toVersion: string,
): Promise<Changelog[]> => {
try {
const res = await fetch(READEST_CHANGELOG_FILE);
const data: ReleaseNotes = await res.json();
const releases = data.releases;
const entries = Object.entries(releases)
.filter(([ver]) => semver.gt(ver, fromVersion) && semver.lte(ver, toVersion))
.sort(([a], [b]) => semver.rcompare(a, b))
.map(([version, info]) => ({
version,
date: new Date(info.date).toDateString(),
notes: info.notes,
}));
return entries;
} catch (error) {
console.error('Failed to fetch changelog:', error);
return [];
}
};
const parseNumberedList = (input: string): string[] => {
return input
.split(/(?:^|\s)\d+\.\s/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
};
const updateChangelogs = async (update: GenericUpdate) => {
setNewVersion(update.version);
const changelogs = await fetchChangelogs(currentVersion, update.version);
if (changelogs.length > 0) {
setChangelogs(changelogs);
} else {
setChangelogs([
{
version: update.version,
date: new Date(update.date!).toDateString(),
notes: parseNumberedList(update.body ?? ''),
},
]);
}
};
if (update) {
updateChangelogs(update);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [update]);
useEffect(() => {
setIsMounted(true);
}, []);
const handleDownloadInstall = async () => {
if (!update) {
return;
}
let downloaded = 0;
let contentLength = 0;
let lastLogged = 0;
setProgress(0);
setIsDownloading(true);
await update.downloadAndInstall?.((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength!;
setContentLength(contentLength);
break;
case 'Progress':
downloaded += event.data.chunkLength;
setDownloaded(downloaded);
const percent = Math.floor((downloaded / contentLength) * 100);
setProgress(percent);
if (downloaded - lastLogged >= 1 * 1024 * 1024) {
console.log(`downloaded ${downloaded} bytes from ${contentLength}`);
lastLogged = downloaded;
}
break;
case 'Finished':
console.log('download finished');
setProgress(100);
break;
}
});
console.log('package installed');
if (!appService?.isAndroidApp && process.env.NODE_ENV === 'production') {
await relaunch();
}
};
if (!isMounted) {
return null;
}
return (
<div className='bg-base-100 flex min-h-screen justify-center'>
<div className='flex w-full max-w-2xl flex-col gap-4'>
<div className='flex flex-col justify-center gap-4 sm:flex-row sm:items-start'>
<div className='flex items-center justify-center'>
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
</div>
<div className='text-base-content flex-grow text-sm'>
<h2 className='mb-4 text-center font-bold sm:text-start'>
{_('A new version of Readest is Available!')}
</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
currentVersion,
})}
</p>
<p className='mb-2'>{_('Download and install now?')}</p>
<div className='flex w-full flex-row items-center justify-end gap-4'>
{progress !== null && (
<div className='flex flex-grow flex-col'>
<progress
className='progress my-1 h-4 w-full'
value={progress}
max='100'
></progress>
<p className='text-base-content/75 flex items-center justify-center text-sm'>
{progress < 100
? _('Downloading {{downloaded}} of {{contentLength}}', {
downloaded: downloaded
? `${Math.floor(downloaded / 1024 / 1024)} MB`
: '0 MB',
contentLength: contentLength
? `${Math.floor(contentLength / 1024 / 1024)} MB`
: '0 MB',
})
: _('Download finished')}
</p>
</div>
)}
<div className='card-actions'>
<button
className={clsx(
'btn btn-warning text-base-100 px-6 font-bold',
(!update || isDownloading) && 'btn-disabled',
)}
onClick={handleDownloadInstall}
>
{_('DOWNLOAD & INSTALL')}
</button>
</div>
</div>
</div>
</div>
<div className='text-base-content text-sm'>
<h3 className='mb-2 font-bold'>{_('Changelog:')}</h3>
<div className='bg-base-300 mb-4 rounded-lg px-4 pb-2 pt-4'>
{changelogs.length > 0 ? (
changelogs.map((entry: Changelog) => (
<div key={entry.version} className='mb-4'>
<h4 className='mb-2 font-bold'>
{entry.version} ({entry.date})
</h4>
<ul className='list-disc space-y-1 ps-6 text-sm'>
{entry.notes.map((note: string, i: number) => (
<li key={i}>{note}</li>
))}
</ul>
</div>
))
) : (
<div className='flex h-56 w-full flex-col gap-4'>
<div className='skeleton h-4 w-28'></div>
<div className='skeleton h-4 w-full'></div>
<div className='skeleton h-4 w-full'></div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export const setUpdaterWindowVisible = (visible: boolean, newVersion?: string) => {
const dialog = document.getElementById('updater_window');
if (dialog) {
const event = new CustomEvent('setDialogVisibility', {
detail: { visible, newVersion },
});
dialog.dispatchEvent(event);
}
};
export const UpdaterWindow = () => {
const _ = useTranslation();
const [newVersion, setNewVersion] = useState('');
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
const { visible, newVersion } = event.detail;
setIsOpen(visible);
if (newVersion) {
setNewVersion(newVersion);
}
};
const el = document.getElementById('updater_window');
if (el) {
el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener);
}
return () => {
if (el) {
el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener);
}
};
}, []);
return (
<Dialog
id='updater_window'
isOpen={isOpen}
title={_('Software Update')}
onClose={() => setIsOpen(false)}
boxClassName='sm:!w-[80%] sm:h-auto'
>
<UpdaterContent version={newVersion ?? undefined} />
</Dialog>
);
};
@@ -18,6 +18,14 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
React.useEffect(() => {
envConfig.getAppService().then((service) => setAppService(service));
window.addEventListener('error', (e) => {
if (e.message === 'ResizeObserver loop limit exceeded') {
e.stopImmediatePropagation();
e.preventDefault();
return true;
}
return false;
});
}, [envConfig]);
return <EnvContext.Provider value={{ envConfig, appService }}>{children}</EnvContext.Provider>;
@@ -1,8 +1,9 @@
import { isWebAppPlatform, hasCli } from '@/services/environment';
import { getCurrent } from '@tauri-apps/plugin-deep-link';
declare global {
interface Window {
OPEN_WITH_FILES?: string[];
OPEN_WITH_FILES?: string[] | null;
}
}
@@ -32,12 +33,35 @@ const parseCLIOpenWithFiles = async () => {
return files;
};
const parseIntentOpenWithFiles = async () => {
const urls = await getCurrent();
if (urls && urls.length > 0) {
console.log('Intent Open with URL:', urls);
return urls
.map((url) => {
if (url.startsWith('file://')) {
return decodeURI(url.replace('file://', ''));
} else if (url.startsWith('content://')) {
return url;
} else {
console.info('Skip non-file URL:', url);
return null;
}
})
.filter((url) => url !== null) as string[];
}
return null;
};
export const parseOpenWithFiles = async () => {
if (isWebAppPlatform()) return [];
let files = parseWindowOpenWithFiles();
if (!files && hasCli()) {
if ((!files || files.length === 0) && hasCli()) {
files = await parseCLIOpenWithFiles();
}
if (!files || files.length === 0) {
files = await parseIntentOpenWithFiles();
}
return files;
};
@@ -7,6 +7,7 @@ export interface ShortcutConfig {
onToggleSelectMode: string[];
onOpenFontLayoutSettings: string[];
onReloadPage: string[];
onToggleFullscreen: string[];
onQuitApp: string[];
onGoLeft: string[];
onGoRight: string[];
@@ -32,6 +33,7 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onToggleSelectMode: ['shift+s'],
onOpenFontLayoutSettings: ['shift+f'],
onReloadPage: ['shift+r'],
onToggleFullscreen: ['F11'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h'],
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
+50 -21
View File
@@ -1,35 +1,64 @@
import semver from 'semver';
import { check } from '@tauri-apps/plugin-updater';
import { type as osType } from '@tauri-apps/plugin-os';
import { fetch } from '@tauri-apps/plugin-http';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { CHECK_UPDATE_INTERVAL_SEC } from '@/services/constants';
import { TranslationFunc } from '@/hooks/useTranslation';
import { setUpdaterWindowVisible } from '@/components/UpdaterWindow';
import { CHECK_UPDATE_INTERVAL_SEC, READEST_UPDATER_FILE } from '@/services/constants';
import packageJson from '../../package.json';
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
export const checkForAppUpdates = async (_: TranslationFunc, autoCheck = true) => {
const showUpdateWindow = (newVersion: string) => {
const win = new WebviewWindow('updater', {
url: `/updater?version=${newVersion}`,
title: 'Software Update',
width: 626,
height: 406,
center: true,
resizable: true,
});
win.once('tauri://created', () => {
console.log('new window created');
});
win.once('tauri://error', (e) => {
console.error('error creating window', e);
});
};
export const checkForAppUpdates = async (
_: TranslationFunc,
autoCheck = true,
): Promise<boolean> => {
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
const now = Date.now();
if (autoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000)
return;
return false;
localStorage.setItem(LAST_CHECK_KEY, now.toString());
console.log('Checking for updates');
const update = await check();
console.log('Update found', update);
if (update) {
const win = new WebviewWindow('updater', {
url: `/updater?version=${update.version}`,
title: 'Software Update',
width: 626,
height: 406,
center: true,
resizable: true,
});
win.once('tauri://created', () => {
console.log('new window created');
});
win.once('tauri://error', (e) => {
console.error('error creating window', e);
});
const OS_TYPE = osType();
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
const update = await check();
if (update) {
showUpdateWindow(update.version);
}
return !!update;
} else if (OS_TYPE === 'android') {
try {
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
const isNewer = semver.gt(data.version, packageJson.version);
if (isNewer && ('android-arm64' in data.platforms || 'android-universal' in data.platforms)) {
setUpdaterWindowVisible(true, data.version);
}
return isNewer;
} catch (err) {
console.warn('Failed to fetch Android update info', err);
return false;
}
}
return update;
return false;
};
@@ -62,7 +62,10 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
return;
}
const headerbar = document.querySelector('.titlebar');
const pullIndicator = document.createElement('div');
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
pullIndicator.style.top = `${headerBottom + 20}px`;
pullIndicator.className = 'pull-indicator text-gray-500';
pullIndicator.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
+7 -6
View File
@@ -63,8 +63,8 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
handler &&
shortcutList?.some((shortcut) =>
isShortcutMatch(shortcut, key, ctrlKey, altKey, metaKey, shiftKey),
)
) {
)
) {
handler();
return true;
}
@@ -76,11 +76,12 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
// Check if the focus is on an input, textarea, or contenteditable element
const activeElement = document.activeElement as HTMLElement;
const isInteractiveElement =
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable);
activeElement.isContentEditable;
const isNoteEditor = (activeElement.tagName === 'TEXTAREA' && activeElement.classList.contains('note-editor'))
const isNoteEditor =
activeElement.tagName === 'TEXTAREA' && activeElement.classList.contains('note-editor');
if (isInteractiveElement && !isNoteEditor) {
return; // Skip handling if the user is typing in an input, textarea, or contenteditable
@@ -89,7 +90,7 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
if (event instanceof KeyboardEvent) {
const { key, ctrlKey, altKey, metaKey, shiftKey } = event;
if (isNoteEditor && !((key === "Enter" && ctrlKey) || (key == "Escape"))) {
if (isNoteEditor && !((key === 'Enter' && ctrlKey) || key == 'Escape')) {
return;
}
+31 -3
View File
@@ -1,16 +1,44 @@
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme } from '@/styles/themes';
import { applyCustomTheme, Palette } from '@/styles/themes';
import { getStatusBarHeight, setSystemUIVisibility } from '@/utils/bridge';
export const useTheme = () => {
type UseThemeProps = {
systemUIVisible?: boolean;
appThemeColor?: keyof Palette;
};
export const useTheme = ({
systemUIVisible = true,
appThemeColor = 'base-100',
}: UseThemeProps = {}) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { themeColor, isDarkMode } = useThemeStore();
const { themeColor, isDarkMode, updateAppTheme, setStatusBarHeight } = useThemeStore();
useEffect(() => {
updateAppTheme(appThemeColor);
if (appService?.isMobile) {
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
}
if (appService?.isAndroidApp) {
getStatusBarHeight().then((res) => {
if (res.height && res.height > 0) {
setStatusBarHeight(res.height / window.devicePixelRatio);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isDarkMode]);
useEffect(() => {
const customThemes = settings.globalReadSettings?.customThemes ?? [];
customThemes.forEach((customTheme) => {
applyCustomTheme(customTheme);
});
localStorage.setItem('customThemes', JSON.stringify(customThemes));
}, [settings]);
useEffect(() => {
+1 -1
View File
@@ -97,7 +97,7 @@ export const downloadFile = async (
return;
}
} catch (error) {
console.error('File download failed:', error);
console.error(`File '${filePath}' download failed:`, error);
throw new Error('File download failed');
}
};
@@ -20,6 +20,11 @@ const getUserAndToken = async (authHeader: string | undefined) => {
return { user, token };
};
const LANG_V2_V1_MAP: Record<string, string> = {
'ZH-HANS': 'ZH',
'ZH-HANT': 'ZH-TW',
};
const getDeepLAPIKey = (keys: string | undefined) => {
const keyArray = keys?.split(',') ?? [];
return keyArray.length ? keyArray[Math.floor(Math.random() * keyArray.length)] : '';
@@ -52,12 +57,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const {
text,
source_lang: sourceLang = 'auto',
target_lang: targetLang = 'en',
source_lang: sourceLang = 'AUTO',
target_lang: targetLang = 'EN',
}: { text: string[]; source_lang: string; target_lang: string } = req.body;
try {
if (user && token) {
console.log('deeplApiUrl', deeplApiUrl);
const isV2Api = deeplApiUrl.endsWith('/v2/translate');
const requestBody = {
text: isV2Api ? text : (text[0] ?? ''),
source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang),
target_lang: isV2Api ? targetLang : (LANG_V2_V1_MAP[targetLang] ?? targetLang),
};
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
@@ -65,10 +75,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
'x-fingerprint': process.env['DEEPL_X_FINGERPRINT'] || '',
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
body: JSON.stringify(requestBody),
});
res.status(response.status);
res.json(await response.json());
const data = await response.json();
const result = {
...data,
translations: data.translations || [
{
text: data.data,
},
],
};
res.status(response.status).json(result);
} else {
const result = await deeplQuery({
text: text[0] ?? '',
+22 -8
View File
@@ -15,6 +15,7 @@ import {
formatTitle,
formatAuthors,
getFilename,
getPrimaryLanguage,
} from '@/utils/book';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader } from '@/libs/document';
@@ -31,6 +32,7 @@ import {
DEFAULT_MOBILE_VIEW_SETTINGS,
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
DEFAULT_MOBILE_READSETTINGS,
} from './constants';
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
@@ -49,6 +51,7 @@ export abstract class BaseAppService implements AppService {
isAppDataSandbox = false;
isAndroidApp = false;
isIOSApp = false;
isMobileApp = false;
hasTrafficLight = false;
hasWindow = false;
hasWindowBar = false;
@@ -56,7 +59,7 @@ export abstract class BaseAppService implements AppService {
hasRoundedWindow = false;
hasSafeAreaInset = false;
hasHaptics = false;
hasSysFontsList = false;
hasUpdater = false;
abstract fs: FileSystem;
@@ -98,7 +101,10 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_SYSTEM_SETTINGS,
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.getInitBooksDir(),
globalReadSettings: DEFAULT_READSETTINGS,
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
},
globalViewSettings: {
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
@@ -136,7 +142,8 @@ export abstract class BaseAppService implements AppService {
async importBook(
// file might be:
// 1. absolute path for local file
// 1.1 absolute path for local file on Desktop
// 1.2 /private/var inbox file path on iOS
// 2. remote url
// 3. content provider uri
// 4. File object from browsers
@@ -192,6 +199,7 @@ export abstract class BaseAppService implements AppService {
format,
title: formatTitle(loadedBook.metadata.title),
author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language),
primaryLanguage: getPrimaryLanguage(loadedBook.metadata.language),
createdAt: existingBook ? existingBook.createdAt : Date.now(),
uploadedAt: existingBook ? existingBook.uploadedAt : null,
deletedAt: transient ? Date.now() : null,
@@ -202,6 +210,7 @@ export abstract class BaseAppService implements AppService {
if (existingBook) {
existingBook.title = book.title;
existingBook.author = book.author;
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
}
if (!(await this.fs.exists(getDir(book), 'Books'))) {
@@ -375,11 +384,16 @@ export abstract class BaseAppService implements AppService {
await this.fs.createDir(getDir(book), 'Books');
}
if (needDownCover) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
try {
if (needDownCover) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
}
} catch (error) {
// don't throw error here since some books may not have cover images at all
console.log('Failed to download cover file:', error);
}
if (needDownBook) {

Some files were not shown because too many files have changed in this diff Show More