Compare commits

...

34 Commits

Author SHA1 Message Date
Huang Xin 89d48c72b0 release: version 0.9.56 (#1391) 2025-06-13 17:35:38 +02:00
Huang Xin 467e80cd4b fix: various fixes on text selector in TTS mode and normal image height (#1390) 2025-06-13 17:28:21 +02:00
Huang Xin 20955ec294 chore: handle client uncaught exceptions and show error message (#1389) 2025-06-13 15:24:24 +02:00
James Sizeland 3cae183f51 feat: add syntax highlighting (#1386)
* add highlightjs

* add github light and dark theme

* inject style classes

* fix typo

* remove conflicting style overrides

* add highlightjs function and language list

* add language select setting

* add language select input

* add watcher to rerender page on changes to language select

* lockfile update

* rename highlightCode variables

* reorder code languages alphabetically

* copy translation style for highlight selector

* move code highlighting below theme color section

* move highlighting toggle logic into settings component

* add conditional statement to docLoadHandler manageSyntaxHighlighting

* reorder imports

* switch to media query for dark mode

* combine useEffect callbacks for code highlighting

* add feature line about code highlighting

* fix table tabbing
2025-06-13 14:40:18 +02:00
Huang Xin 99319ab4d1 fix: TTS now properly loads the next chapter in the background, closes #1382 (#1388) 2025-06-13 13:54:17 +02:00
Huang Xin 658969e97a feat: support native tts engine on Android (#1387) 2025-06-12 17:56:14 +02:00
James Sizeland 28c6c1ec58 css: add more aggressive overrides for background and text colour (#1384)
* add more aggressive overrides for background and text colour

* reduce styles changes
2025-06-12 14:24:50 +02:00
James Sizeland d8cc220c18 chore: add vscode settings to include inlayhints (#1385) 2025-06-12 14:22:49 +02:00
Huang Xin 97d7eb659c bump: sync with upstream foliate-js (#1381) 2025-06-11 05:11:06 +02:00
Ahmed 9766ec0125 i18n: improve some Arabic strings in translation.json (#1380) 2025-06-11 03:05:49 +02:00
Huang Xin e46d01448b css: handle inline image height (#1379) 2025-06-10 08:43:00 +02:00
Huang Xin bb8bf99b40 Also transform CSS for azw3 files (#1378) 2025-06-10 06:39:15 +02:00
Huang Xin f8ac30adf1 tts: add native tts plugin for Android (#1376) 2025-06-09 16:54:12 +02:00
Huang Xin 69d418aa61 perf: optimize toc list view for very large toc list, closes #386 (#1370) 2025-06-08 15:01:34 +02:00
Huang Xin 95af3bd3d1 release: version 0.9.55 (#1366)
* release: version 0.9.55

* layout: fix drag handler for multiple dialog instances
2025-06-07 07:55:36 +02:00
Huang Xin 5bdc29fe84 perf: optimize by removing unnecessary animations on Android, closes #1360 (#1365) 2025-06-07 06:53:53 +02:00
Huang Xin 56f4b275f4 translator: toast for Daily Quota Exceeded error (#1363) 2025-06-06 16:24:14 +02:00
Huang Xin 00b46dd8bb bump: upgrade next.js to latest version (#1362) 2025-06-06 15:40:33 +02:00
Huang Xin 8ce89ca8f6 bump: upgrade supabase.js to latest version (#1361) 2025-06-06 14:37:29 +02:00
Huang Xin 648d9ec260 css: unset justify text align when overriding layout, closes #1338 (#1358) 2025-06-06 12:08:53 +02:00
Huang Xin ebe1c10c84 sync: also update deleted notes in synchronization, closes #1356 (#1357) 2025-06-06 11:47:26 +02:00
Huang Xin f2309ef496 release: version 0.9.53 (#1351) 2025-06-05 17:42:22 +02:00
Huang Xin 8d71593f39 fix: exit select mode when all books are deleted, closes #1347 (#1350) 2025-06-05 17:26:55 +02:00
Huang Xin 09e3c30a2a feat: add daily translation quota of DeepL API for more sustainable service (#1349) 2025-06-05 16:58:07 +02:00
Huang Xin eee4cbeaca fix: avoid additional click after books is downloaded before opening (#1346) 2025-06-05 11:43:46 +02:00
Huang Xin 00deca5029 bump: upgrade opennext and wrangler to latest versions (#1345) 2025-06-05 10:46:52 +02:00
Huang Xin c7c20fec55 epub: last resort to get the first image in manifest as book cover (#1343) 2025-06-05 08:40:48 +02:00
Huang Xin 57e22dc7b6 layout: use system select widget for language selection, closes #1339 (#1342) 2025-06-05 08:19:24 +02:00
Huang Xin debd60fb70 css: inline image only as direct children of p and span, closes #1340 (#1341) 2025-06-05 07:47:56 +02:00
Huang Xin dfa17c1c0b css: auto height for single images in anchor (#1337) 2025-06-04 20:08:16 +02:00
Huang Xin 2a26f8b04a fix: trigger library update for the books counter after importing books (#1336) 2025-06-04 19:02:49 +02:00
Huang Xin db955d2c8c feat: add an option to override book fg/bg color, closes #1328 (#1335) 2025-06-04 18:58:53 +02:00
Huang Xin 51602a77fb tts: disable media session controls to keep alive tts (#1333) 2025-06-04 08:17:33 +02:00
Huang Xin 48da5ab546 fix: update current bookshelf after importing and deleting books (#1331) 2025-06-03 19:33:23 +02:00
122 changed files with 4995 additions and 1333 deletions
+9
View File
@@ -4,4 +4,13 @@
"packages/tauri/Cargo.toml",
"apps/readest-app/src-tauri/Cargo.toml"
],
// "editor.formatOnSave": true, // uncomment to add format on save
"typescript.inlayHints.parameterNames.enabled": "all",
"typescript.inlayHints.variableTypes.enabled": true,
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
"typescript.inlayHints.enumMemberValues.enabled": true,
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.format.enable": false,
}
Generated
+12
View File
@@ -37,6 +37,7 @@ dependencies = [
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-native-bridge",
"tauri-plugin-native-tts",
"tauri-plugin-oauth",
"tauri-plugin-opener",
"tauri-plugin-os",
@@ -5443,6 +5444,17 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "tauri-plugin-native-tts"
version = "0.1.0"
dependencies = [
"schemars",
"serde",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
]
[[package]]
name = "tauri-plugin-oauth"
version = "2.0.0"
+2 -1
View File
@@ -55,6 +55,7 @@
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
## Planned Features
@@ -94,7 +95,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
The Readest app is available for download! 🥳 🚀
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to readestapp@gmail.com to request access).
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Web: Visit [https://web.readest.com][link-web-readest].
+5
View File
@@ -1,8 +1,13 @@
import withPWAInit from '@ducanh2912/next-pwa';
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
const isDev = process.env['NODE_ENV'] === 'development';
const appPlatform = process.env['NEXT_PUBLIC_APP_PLATFORM'];
if (isDev) {
initOpenNextCloudflareForDev();
}
/** @type {import('next').NextConfig} */
const nextConfig = {
// Ensure Next.js uses SSG instead of SSR
+5 -28
View File
@@ -1,29 +1,6 @@
// @ts-nocheck
// default open-next.config.ts file created by @opennextjs/cloudflare
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js';
import cache from '@opennextjs/cloudflare/kv-cache';
const config: OpenNextConfig = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
// set `incrementalCache` to "dummy" to disable KV cache
incrementalCache: async () => cache,
tagCache: 'dummy',
queue: 'dummy',
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
},
},
};
export default config;
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});
+11 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.52",
"version": "0.9.56",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -30,9 +30,10 @@
"build-ios-appstore": "dotenv -e .env.ios-appstore.local -- tauri ios build --export-method app-store-connect",
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh",
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler dev",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler deploy",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
},
"dependencies": {
@@ -40,9 +41,10 @@
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.1.0",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"@supabase/supabase-js": "^2.49.10",
"@tauri-apps/api": "2.5.0",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.1",
@@ -62,13 +64,14 @@
"cors": "^2.8.5",
"cssbeautify": "^0.3.1",
"foliate-js": "workspace:*",
"highlight.js": "^11.11.1",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"marked": "^15.0.12",
"next": "15.2.4",
"next": "15.3.3",
"posthog-js": "^1.246.0",
"react": "19.0.0",
"react-color": "^2.19.3",
@@ -76,12 +79,12 @@
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-responsive": "^10.0.0",
"react-window": "^1.8.11",
"semver": "^7.7.1",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
},
"devDependencies": {
"@opennextjs/cloudflare": "^0.5.12",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.5.0",
"@types/cors": "^2.8.17",
@@ -90,6 +93,7 @@
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
"@types/react-window": "^1.8.8",
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
@@ -107,6 +111,6 @@
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"wrangler": "^4.4.0"
"wrangler": "^4.19.1"
}
}
+2
View File
@@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable
@@ -19,7 +19,7 @@
"Copied to notebook": "تم النسخ إلى المفكرة",
"Copy": "نسخ",
"Custom CSS": "CSS مخصص",
"Dark Mode": "الوضع المظلم",
"Dark Mode": "الوضع الداكن",
"Default": "الافتراضي",
"Default Font": "الخط الافتراضي",
"Default Font Size": "حجم الخط الافتراضي",
@@ -186,28 +186,27 @@
"Sign in with Apple": "تسجيل الدخول عبر Apple",
"Sign in with GitHub": "تسجيل الدخول عبر GitHub",
"Account": "الحساب",
"Cloud Storage": "التخزين السحابي",
"Failed to delete user. Please try again later.": "فشل حذف المستخدم. يرجى المحاولة مرة أخرى لاحقًا.",
"Unlimited Offline/Online Reading": "قراءة غير محدودة بدون اتصال/متصلة بالإنترنت",
"Unlimited Cloud Sync Devices": "أجهزة مزامنة سحابية غير محدودة",
"Unlimited Offline/Online Reading": "القراءة بدون حدود للكتب المتوفر على الجهاز أو عبر الانترنت",
"Unlimited Cloud Sync Devices": "مزامنة عبر السحابة لعدد غير محدود من الأجهزة",
"Essential Text-to-Speech Voices": "أصوات أساسية لتحويل النص إلى كلام",
"DeepL Free Access": "وصول مجاني إلى DeepL",
"DeepL Free Access": "وصول مجاني لـ DeepL",
"Community Support": "دعم المجتمع",
"500 MB Cloud Sync Space": ساحة مزامنة سحابية 500 ميجابايت",
"Includes All Free Tier Benefits": "يتضمن جميع مزايا الفئة المجانية",
"500 MB Cloud Sync Space": "مزامنة سحابية بمساحة تخزين 500 ميجابايت",
"Includes All Free Tier Benefits": "تشمل جميع مزايا الفئة المجانية",
"AI Summaries": "ملخصات الذكاء الاصطناعي",
"AI Translations": "ترجمات الذكاء الاصطناعي",
"Priority Support": "دعم ذو أولوية",
"DeepL Pro Access": "وصول إلى DeepL Pro",
"DeepL Pro Access": "وصول لميزات DeepL Pro",
"Expanded Text-to-Speech Voices": "أصوات موسعة لتحويل النص إلى كلام",
"2000 MB Cloud Sync Space": ساحة مزامنة سحابية 2000 ميجابايت",
"Includes All Plus Tier Benefits": "يتضمن جميع مزايا فئة Plus",
"2000 MB Cloud Sync Space": "مزامنة سحابية بمساحة تخزين 2000 ميجابايت",
"Includes All Plus Tier Benefits": "تشمل جميع مزايا فئة Plus",
"Unlimited AI Summaries": "ملخصات ذكاء اصطناعي غير محدودة",
"Unlimited AI Translations": "ترجمات ذكاء اصطناعي غير محدودة",
"Unlimited DeepL Pro Access": "وصول غير محدود إلى DeepL Pro",
"Unlimited DeepL Pro Access": "وصول غير محدود لميزات DeepL Pro",
"Advanced AI Tools": "أدوات ذكاء اصطناعي متقدمة",
"Early Feature Access": "وصول مبكر للميزات",
"10 GB Cloud Sync Space": ساحة مزامنة سحابية 10 جيجابايت",
"10 GB Cloud Sync Space": "مزامنة سحابية بمساحة تخزين 10 جيجابايت",
"Loading profile...": "جارٍ تحميل الملف الشخصي...",
"Features": "الميزات",
"Delete Account": "حذف الحساب",
@@ -223,7 +222,7 @@
"Continuous Scroll": "التمرير المستمر",
"Fullscreen": "ملء الشاشة",
"No supported files found. Supported formats: {{formats}}": "لم يتم العثور على ملفات مدعومة. الصيغ المدعومة: {{formats}}",
"Drop to Import Books": "اسحب لاستيراد الكتب",
"Drop to Import Books": "قم بالسحب والإسقاط لاستيراد الكتب",
"Custom": "مخصص",
"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": "العالم مسرح،\nوالناس فيه ممثلون؛\nلهم مخارج ومداخل،\nوالإنسان في حياته يلعب دورًا كثيرًا،\nوأفعاله تمر بسبع مراحل.\n\n— ويليام شكسبير",
"Custom Theme": "سمة مخصصة",
@@ -236,8 +235,8 @@
"Double Border": "حدود مزدوجة",
"Border Color": "لون الحدود",
"Border Frame": "إطار الحدود",
"Show Header": "عرض الرأس",
"Show Footer": "عرض التذييل",
"Show Header": "إظهار الترويسة",
"Show Footer": "إظهار التذييل",
"Small": "صغير",
"Large": "كبير",
"Auto": "تلقائي",
@@ -252,24 +251,24 @@
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"No chapters detected.": "لم يتم التعرف على فصول.",
"No chapters detected.": "لم يتم التعرف على أي فصول.",
"Failed to parse the EPUB file.": "فشل في تحليل ملف EPUB.",
"This book format is not supported.": "هذا تنسيق الكتاب غير مدعوم.",
"This book format is not supported.": "تنسيق الكتاب هذا غير مدعوم.",
"Unable to fetch the translation. Please log in first and try again.": "تعذر جلب الترجمة. يرجى تسجيل الدخول أولاً ثم المحاولة مرة أخرى.",
"Group": "تجميع",
"Always on Top": "دائمًا في المقدمة",
"No Timeout": "لا مهلة",
"No Timeout": "بدون مهلة",
"{{value}} minute": "{{value}} دقيقة",
"{{value}} minutes": "{{value}} دقائق",
"{{value}} hour": "{{value}} ساعة",
"{{value}} hours": "{{value}} ساعات",
"CJK Font": "خط CJK",
"Clear Search": "مسح البحث",
"Header & Footer": "الرأس والتذييل",
"Header & Footer": "الترويسة والتذييل",
"Apply also in Scrolled Mode": "تطبيق أيضًا في وضع التمرير",
"A new version of Readest is Available!": "إصدار جديد من ريديست متاح!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "إصدار ريديست {{newVersion}} متاح (الإصدار المثبت {{currentVersion}}).",
"Download and install now?": "هل ترغب في تنزيل وتثبيت الآن؟",
"A new version of Readest is Available!": "يتوفر إصدار جديد من Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "إصدار {{newVersion}} من ريديست متاح للتنزيل (الإصدار المثبت حاليًا {{currentVersion}}).",
"Download and install now?": "هل ترغب في تنزيله وتثبيته الآن؟",
"Downloading {{downloaded}} of {{contentLength}}": "جارٍ تنزيل {{downloaded}} من {{contentLength}}",
"Download finished": "اكتمل التنزيل",
"DOWNLOAD & INSTALL": "تنزيل وتثبيت",
@@ -296,8 +295,8 @@
"Swap Clicks Area": "تبديل منطقة النقرات",
"Screen": "الشاشة",
"Orientation": "الاتجاه",
"Portrait": "العمودي",
"Landscape": "الأفقي",
"Portrait": "عمودي",
"Landscape": "أفقي",
"Open Last Book on Start": "فتح آخر كتاب عند البدء",
"Checking for updates...": "جارٍ التحقق من التحديثات...",
"Error checking for updates": "خطأ في التحقق من التحديثات",
@@ -315,14 +314,14 @@
"Are you sure to delete {{count}} selected book(s)?_many": "هل أنت متأكد من حذف {{count}} كتابًا محددًا؟",
"Are you sure to delete {{count}} selected book(s)?_other": "هل أنت متأكد من حذف {{count}} من الكتب المحددة؟",
"Are you sure to delete the selected book?": "هل أنت متأكد من حذف الكتاب المحدد؟",
"Deselect": "إلغاء",
"Select All": "الكل",
"Deselect": "إلغاء التحديد",
"Select All": "تحديد الكل",
"No translation available.": "لا توجد ترجمة متاحة.",
"Translated by {{provider}}.": "ترجم بواسطة {{provider}}.",
"DeepL": "ديب إل",
"Google Translate": "ترجمة جوجل",
"Azure Translator": "مترجم أزور",
"Invert Image In Dark Mode": "عكس الصورة في الوضع المظلم",
"Translated by {{provider}}.": مت الترجمة بواسطة {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "ترجمة Google",
"Azure Translator": "مترجم Azure",
"Invert Image In Dark Mode": "عكس ألوان الصورة في الوضع الداكن",
"Help improve Readest": "ساعد في تحسين ريديست",
"Sharing anonymized statistics": "مشاركة إحصائيات مجهولة الهوية",
"Interface Language": "لغة الواجهة",
@@ -337,7 +336,7 @@
"System Language": "لغة النظام",
"Security": "الأمان",
"Allow JavaScript": "السماح بـ JavaScript",
"Enable only if you trust the file.": "تمكين فقط إذا كنت تثق في الملف.",
"Enable only if you trust the file.": "قم بالتمكين فقط في حال وثوقك بالملف.",
"Sort TOC by Page": "ترتيب جدول المحتويات حسب الصفحة",
"Search in {{count}} Book(s)..._zero": "بحث في الكتب...",
"Search in {{count}} Book(s)..._one": "بحث في كتاب...",
@@ -347,10 +346,30 @@
"Search in {{count}} Book(s)..._other": "بحث في {{count}} من الكتب...",
"No notes match your search": "لا توجد ملاحظات تطابق بحثك",
"Search notes and excerpts...": "بحث في الملاحظات والمقتطفات...",
"Sign in to Sync": "سجّل للمزامنة",
"Synced at {{time}}": "تمت عند {{time}}",
"Never synced": "لم تُزامَن",
"Sign in to Sync": "قم بتسجيل الدخول للمزامنة",
"Synced at {{time}}": "تمت المزامنة في {{time}}",
"Never synced": "لم تتم المزامنة بعد",
"Show Remaining Time": "إظهار الوقت المتبقي",
"Show Page Number": "إظهار رقم الصفحة",
"{{time}} min left in chapter": "{{time}} دقيقة متبقية في الفصل"
"{{time}} min left in chapter": "{{time}} دقيقة متبقية حتى نهاية الفصل",
"Override Book Color": "تجاوز لون الكتاب",
"Login Required": "يتطلب الأمر تسجيل الدخول",
"Quota Exceeded": "تجاوزت الحصة المخصصة",
"{{percentage}}% of Daily Translation Characters Used.": "تم استخدام ما نسبته {{percentage}}% من إجمالي أحرف الترجمة المسموح بها يوميًا.",
"Translation Characters": "أحرف الترجمة",
"Daily translation quota reached. Select another translate service to proceed.": "تم استنفاد حصة الترجمة اليومية. قم بتحديد خدمة ترجمة أخرى للاستمرار.",
"{{engine}}: {{count}} voices_zero": "{{engine}}: لا توجد أصوات",
"{{engine}}: {{count}} voices_one": "{{engine}}: صوت واحد",
"{{engine}}: {{count}} voices_two": "{{engine}}: صوتان",
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} أصوات",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} صوتاً",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} صوت",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "حدث خطأ ما. لا تقلق، لقد تم إبلاغ فريقنا ونحن نعمل على إصلاحه.",
"Error Details:": "تفاصيل الخطأ:",
"Try Again": "حاول مرة أخرى",
"Need help?": "تحتاج مساعدة؟",
"Contact Support": "اتصل بالدعم الفني",
"Code Highlighting": "تمييز الكود",
"Enable Highlighting": "تمكين التمييز",
"Code Language": "لغة الكود"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Mit Apple anmelden",
"Sign in with GitHub": "Mit GitHub anmelden",
"Account": "Konto",
"Cloud Storage": "Cloud-Speicher",
"Failed to delete user. Please try again later.": "Benutzer konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut.",
"Unlimited Offline/Online Reading": "Unbegrenztes Offline/Online Lesen",
"Unlimited Cloud Sync Devices": "Unbegrenzte Cloud-Synchronisierungsgeräte",
@@ -344,5 +343,21 @@
"Never synced": "Nie synchronisiert",
"Show Remaining Time": "Verbleibende Zeit anzeigen",
"Show Page Number": "Seitenzahl anzeigen",
"{{time}} min left in chapter": "{{time}} min verbleibend im Kapitel"
"{{time}} min left in chapter": "{{time}} min verbleibend im Kapitel",
"Override Book Color": "Farbe des Buches überschreiben",
"Login Required": "Anmeldung nötig",
"Quota Exceeded": "Limit überschritten",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% der täglichen Übersetzungszeichen verwendet.",
"Translation Characters": "Übersetzungszeichen",
"Daily translation quota reached. Select another translate service to proceed.": "Das tägliche Übersetzungslimit wurde erreicht. Wählen Sie einen anderen Übersetzungsdienst, um fortzufahren.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} Stimme",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} Stimmen",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Etwas ist schief gelaufen. Keine Sorge, unser Team wurde benachrichtigt und arbeitet an einer Lösung.",
"Error Details:": "Fehlerdetails:",
"Try Again": "Erneut versuchen",
"Need help?": "Brauchen Sie Hilfe?",
"Contact Support": "Support kontaktieren",
"Code Highlighting": "Code-Hervorhebung",
"Enable Highlighting": "Code-Hervorhebung aktivieren",
"Code Language": "Programmiersprache"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Σύνδεση με Apple",
"Sign in with GitHub": "Σύνδεση με GitHub",
"Account": "Λογαριασμός",
"Cloud Storage": "Αποθηκευτικός χώρος Cloud",
"Failed to delete user. Please try again later.": "Αποτυχία διαγραφής χρήστη. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"Unlimited Offline/Online Reading": "Απεριόριστη ανάγνωση εκτός/εντός σύνδεσης",
"Unlimited Cloud Sync Devices": "Απεριόριστες συσκευές συγχρονισμού Cloud",
@@ -344,5 +343,21 @@
"Never synced": "Ποτέ δεν συγχρονίστηκε",
"Show Remaining Time": "Εμφάνιση υπολειπόμενου χρόνου",
"Show Page Number": "Εμφάνιση αριθμού σελίδας",
"{{time}} min left in chapter": "{{time}} λεπτά απομένουν στο κεφάλαιο"
"{{time}} min left in chapter": "{{time}} λεπτά απομένουν στο κεφάλαιο",
"Override Book Color": "Παράκαμψη χρώματος βιβλίου",
"Login Required": "Απαιτείται σύνδεση",
"Quota Exceeded": "Υπέρβαση ορίου",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% των ημερήσιων χαρακτήρων μετάφρασης χρησιμοποιήθηκαν.",
"Translation Characters": "Χαρακτήρες μετάφρασης",
"Daily translation quota reached. Select another translate service to proceed.": "Έχετε φτάσει το ημερήσιο όριο χαρακτήρων μετάφρασης. Επιλέξτε άλλη υπηρεσία μετάφρασης για να συνεχίσετε.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} φωνή",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} φωνές",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Κάτι πήγε στραβά. Μην ανησυχείτε, η ομάδα μας έχει ενημερωθεί και εργαζόμαστε για μια λύση.",
"Error Details:": "Λεπτομέρειες σφάλματος:",
"Try Again": "Δοκιμάστε ξανά",
"Need help?": "Χρειάζεστε βοήθεια;",
"Contact Support": "Επικοινωνήστε με την υποστήριξη",
"Code Highlighting": "Επισήμανση κώδικα",
"Enable Highlighting": "Ενεργοποίηση επισήμανσης",
"Code Language": "Γλώσσα κώδικα"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Iniciar sesión con Apple",
"Sign in with GitHub": "Iniciar sesión con GitHub",
"Account": "Cuenta",
"Cloud Storage": "Almacenamiento en la nube",
"Failed to delete user. Please try again later.": "Error al eliminar usuario. Por favor, inténtelo de nuevo más tarde.",
"Unlimited Offline/Online Reading": "Lectura sin límites en línea/sin conexión",
"Unlimited Cloud Sync Devices": "Sincronización ilimitada de dispositivos en la nube",
@@ -346,5 +345,22 @@
"Never synced": "Nunca sincronizado",
"Show Remaining Time": "Mostrar tiempo restante",
"Show Page Number": "Mostrar número de página",
"{{time}} min left in chapter": "{{time}} min restantes en el capítulo"
"{{time}} min left in chapter": "{{time}} min restantes en el capítulo",
"Override Book Color": "Sobre escribir color del libro",
"Login Required": "Iniciar sesión",
"Quota Exceeded": "Límite superado",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% de caracteres de traducción diarios utilizados.",
"Translation Characters": "Caracteres de traducción",
"Daily translation quota reached. Select another translate service to proceed.": "Cuota diaria de traducción alcanzada. Selecciona otro servicio de traducción para continuar.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voz",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} voces",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} voces",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Algo salió mal. No te preocupes, nuestro equipo ha sido notificado y estamos trabajando en una solución.",
"Error Details:": "Detalles del error:",
"Try Again": "Intentar de nuevo",
"Need help?": "¿Necesitas ayuda?",
"Contact Support": "Contactar con soporte",
"Code Highlighting": "Resaltado de código",
"Enable Highlighting": "Activar resaltado",
"Code Language": "Lenguaje de código"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Se connecter avec Apple",
"Sign in with GitHub": "Se connecter avec GitHub",
"Account": "Compte",
"Cloud Storage": "Stockage cloud",
"Failed to delete user. Please try again later.": "Échec de la suppression de l'utilisateur. Veuillez réessayer plus tard.",
"Unlimited Offline/Online Reading": "Lecture illimitée hors ligne/en ligne",
"Unlimited Cloud Sync Devices": "Synchronisation cloud sur appareils illimitée",
@@ -346,5 +345,22 @@
"Never synced": "Jamais synchronisé",
"Show Remaining Time": "Afficher le temps restant",
"Show Page Number": "Afficher le numéro de page",
"{{time}} min left in chapter": "{{time}} min restant dans le chapitre"
"{{time}} min left in chapter": "{{time}} min restant dans le chapitre",
"Override Book Color": "Remplacer la couleur du livre",
"Login Required": "Connexion requise",
"Quota Exceeded": "Quota dépassé",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% des caractères de traduction quotidiens utilisés.",
"Translation Characters": "Caractères de traduction",
"Daily translation quota reached. Select another translate service to proceed.": "Quota de traduction quotidien atteint. Sélectionnez un autre service de traduction pour continuer.",
"{{engine}}: {{count}} voices_one": "{{engine}} : {{count}} voix",
"{{engine}}: {{count}} voices_many": "{{engine}} : {{count}} voix",
"{{engine}}: {{count}} voices_other": "{{engine}} : {{count}} voix",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Quelque chose s'est mal passé. Ne vous inquiétez pas, notre équipe a été informée et nous travaillons sur une solution.",
"Error Details:": "Erreur :",
"Try Again": "Réessayer",
"Need help?": "Besoin d'aide ?",
"Contact Support": "Contacter le support",
"Code Highlighting": "Coloration syntaxique",
"Enable Highlighting": "Activer la coloration syntaxique",
"Code Language": "Langage de programmation"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Apple के साथ साइन इन करें",
"Sign in with GitHub": "GitHub के साथ साइन इन करें",
"Account": "खाता",
"Cloud Storage": "क्लाउड स्टोरेज",
"Failed to delete user. Please try again later.": "उपयोगकर्ता को हटाने में विफल। कृपया बाद में पुनः प्रयास करें।",
"Unlimited Offline/Online Reading": "असीमित ऑफलाइन/ऑनलाइन पढ़ना",
"Unlimited Cloud Sync Devices": "असीमित क्लाउड सिंक उपकरण",
@@ -344,5 +343,21 @@
"Never synced": "कभी सिंक नहीं किया गया",
"Show Remaining Time": "शेष समय दिखाएं",
"Show Page Number": "पृष्ठ संख्या दिखाएं",
"{{time}} min left in chapter": "{{time}} मिनट शेष अध्याय में"
"{{time}} min left in chapter": "{{time}} मिनट शेष अध्याय में",
"Override Book Color": "पुस्तक रंग ओवरराइड करें",
"Login Required": "लॉगिन ज़रूरी",
"Quota Exceeded": "सीमा पार",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% दैनिक अनुवाद वर्णों का उपयोग किया गया है।",
"Translation Characters": "अनुवाद वर्ण",
"Daily translation quota reached. Select another translate service to proceed.": "दैनिक अनुवाद कोटा पूरा हो गया है। आगे बढ़ने के लिए कोई अन्य अनुवाद सेवा चुनें।",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} आवाज़",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} आवाज़ें",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "कुछ गलत हो गया। चिंता न करें, हमारी टीम को सूचित कर दिया गया है और हम एक समाधान पर काम कर रहे हैं।",
"Error Details:": "त्रुटि विवरण:",
"Try Again": "फिर से प्रयास करें",
"Need help?": "मदद चाहिए?",
"Contact Support": "सहायता से संपर्क करें",
"Code Highlighting": "कोड हाइलाइटिंग",
"Enable Highlighting": "हाइलाइटिंग सक्षम करें",
"Code Language": "कोड भाषा"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Masuk dengan Apple",
"Sign in with GitHub": "Masuk dengan GitHub",
"Account": "Akun",
"Cloud Storage": "Penyimpanan Cloud",
"Failed to delete user. Please try again later.": "Gagal menghapus pengguna. Silakan coba lagi nanti.",
"Unlimited Offline/Online Reading": "Membaca Offline/Online Tanpa Batas",
"Unlimited Cloud Sync Devices": "Perangkat Sinkronisasi Cloud Tanpa Batas",
@@ -340,7 +339,22 @@
"Sign in to Sync": "Masuk untuk Sinkronisasi",
"Synced at {{time}}": "Disinkronkan pada {{time}}",
"Never synced": "Belum pernah disinkronkan",
"Show Remaining Time": "__STRING_NOT_TRANSLATED__",
"Show Page Number": "__STRING_NOT_TRANSLATED__",
"{{time}} min left in chapter": "{{time}} menit tersisa di bab"
"Show Remaining Time": "Perlihatkan Waktu Tersisa",
"Show Page Number": "Perlihatkan Nomor Halaman",
"{{time}} min left in chapter": "{{time}} menit tersisa di bab",
"Override Book Color": "Ubah Warna Buku",
"Login Required": "Perlu masuk",
"Quota Exceeded": "Kuota terlampaui",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dari Karakter Terjemahan Harian Digunakan.",
"Translation Characters": "Karakter Terjemahan",
"Daily translation quota reached. Select another translate service to proceed.": "Kuota terjemahan harian tercapai. Pilih layanan terjemahan lain untuk melanjutkan.",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} suara",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Terjadi kesalahan. Jangan khawatir, tim kami telah diberitahu dan kami sedang bekerja untuk memperbaikinya.",
"Error Details:": "Rincian Kesalahan:",
"Try Again": "Coba Lagi",
"Need help?": "Butuh bantuan?",
"Contact Support": "Hubungi Dukungan",
"Code Highlighting": "Penyorotan Kode",
"Enable Highlighting": "Aktifkan Penyorotan",
"Code Language": "Bahasa Kode"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Accedi con Apple",
"Sign in with GitHub": "Accedi con GitHub",
"Account": "Account",
"Cloud Storage": "Archiviazione Cloud",
"Failed to delete user. Please try again later.": "Impossibile eliminare l'utente. Riprova più tardi.",
"Unlimited Offline/Online Reading": "Lettura offline/online illimitata",
"Unlimited Cloud Sync Devices": "Sincronizzazione cloud su dispositivi illimitati",
@@ -346,5 +345,22 @@
"Never synced": "Mai sincronizzato",
"Show Remaining Time": "Mostra tempo rimanente",
"Show Page Number": "Mostra numero pagina",
"{{time}} min left in chapter": "{{time}} min rimasti nel capitolo"
"{{time}} min left in chapter": "{{time}} min rimasti nel capitolo",
"Override Book Color": "Override colore libro",
"Login Required": "Accesso richiesto",
"Quota Exceeded": "Limite superato",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dei caratteri di traduzione giornalieri utilizzati.",
"Translation Characters": "Caratteri di traduzione",
"Daily translation quota reached. Select another translate service to proceed.": "Quota di traduzione giornaliera raggiunta. Seleziona un altro servizio di traduzione per procedere.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voce",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} voci",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} voci",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Qualcosa è andato storto. Non preoccuparti, il nostro team è stato avvisato e stiamo lavorando a una soluzione.",
"Error Details:": "Dettagli errore:",
"Try Again": "Riprova",
"Need help?": "Hai bisogno di aiuto?",
"Contact Support": "Contatta il supporto",
"Code Highlighting": "Evidenziazione codice",
"Enable Highlighting": "Abilita evidenziazione",
"Code Language": "Tipo di codice"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Appleでサインイン",
"Sign in with GitHub": "GitHubでサインイン",
"Account": "アカウント",
"Cloud Storage": "クラウドストレージ",
"Failed to delete user. Please try again later.": "ユーザーの削除に失敗しました。後ほど再試行してください。",
"Unlimited Offline/Online Reading": "無制限のオフライン/オンライン読書",
"Unlimited Cloud Sync Devices": "無制限のクラウド同期デバイス",
@@ -342,5 +341,20 @@
"Never synced": "まだ同期されていません",
"Show Remaining Time": "残り時間を表示",
"Show Page Number": "ページ番号を表示",
"{{time}} min left in chapter": "{{time}}分残り"
"{{time}} min left in chapter": "{{time}}分残り",
"Override Book Color": "書籍の色を上書き",
"Login Required": "ログイン必須",
"Quota Exceeded": "上限超過",
"{{percentage}}% of Daily Translation Characters Used.": "日次翻訳文字数の使用量は{{percentage}}%です。",
"Translation Characters": "翻訳文字数",
"Daily translation quota reached. Select another translate service to proceed.": "日次翻訳クォータに達しました。続行するには別の翻訳サービスを選択してください。",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}音声",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "何か問題が発生しました。心配しないでください。私たちのチームが通知を受けており、修正に取り組んでいます。",
"Error Details:": "エラーの詳細:",
"Try Again": "再試行",
"Need help?": "ヘルプが必要ですか?",
"Contact Support": "サポートに連絡",
"Code Highlighting": "コードハイライト",
"Enable Highlighting": "ハイライトを有効にする",
"Code Language": "コード言語"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Apple로 로그인",
"Sign in with GitHub": "GitHub로 로그인",
"Account": "계정",
"Cloud Storage": "클라우드 스토리지",
"Failed to delete user. Please try again later.": "사용자 삭제에 실패했습니다. 나중에 다시 시도해 주세요.",
"Unlimited Offline/Online Reading": "무제한 오프라인/온라인 읽기",
"Unlimited Cloud Sync Devices": "무제한 클라우드 동기화 기기",
@@ -342,5 +341,20 @@
"Never synced": "동기화된 적 없음",
"Show Remaining Time": "남은 시간 표시",
"Show Page Number": "페이지 번호 표시",
"{{time}} min left in chapter": "{{time}}분 남음"
"{{time}} min left in chapter": "{{time}}분 남음",
"Override Book Color": "책 색상 덮어쓰기",
"Login Required": "로그인 필요",
"Quota Exceeded": "한도 초과",
"{{percentage}}% of Daily Translation Characters Used.": "일일 번역 문자 사용량 {{percentage}}%.",
"Translation Characters": "번역 문자",
"Daily translation quota reached. Select another translate service to proceed.": "일일 번역 할당량에 도달했습니다. 계속 진행하려면 다른 번역 서비스를 선택하세요.",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}개의 음성",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "문제가 발생했습니다. 걱정하지 마세요, 저희 팀이 이미 알림을 받았으며 해결 작업을 진행 중입니다.",
"Error Details:": "오류 세부사항:",
"Try Again": "다시 시도",
"Need help?": "도움이 필요하신가요?",
"Contact Support": "지원팀에 문의",
"Code Highlighting": "코드 하이라이팅",
"Enable Highlighting": "하이라이팅 활성화",
"Code Language": "코드 언어"
}
@@ -228,7 +228,6 @@
"Slow": "Langzaam",
"Fast": "Snel",
"Reading Progress Synced": "Leesvoortgang gesynchroniseerd",
"Cloud Storage": "Cloudopslag",
"Failed to delete user. Please try again later.": "Gebruiker verwijderen mislukt. Probeer het later opnieuw.",
"Free Tier": "Gratis niveau",
"Community Support": "Community-ondersteuning",
@@ -344,5 +343,21 @@
"Never synced": "Nooit gesynchroniseerd",
"Show Remaining Time": "Toon resterende tijd",
"Show Page Number": "Toon paginanummer",
"{{time}} min left in chapter": "Nog {{time}} min in hoofdstuk"
"{{time}} min left in chapter": "Nog {{time}} min in hoofdstuk",
"Override Book Color": "Boekkleur overschrijven",
"Login Required": "Inloggen vereist",
"Quota Exceeded": "Limiet overschreden",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% van de dagelijkse vertaaltekens gebruikt.",
"Translation Characters": "Vertaaltekens",
"Daily translation quota reached. Select another translate service to proceed.": "Dagelijkse vertaalquotum bereikt. Selecteer een andere vertaalservice om door te gaan.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} stem",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} stemmen",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Er is iets misgegaan. Maak je geen zorgen, ons team is op de hoogte gebracht en we werken aan een oplossing.",
"Error Details:": "Foutdetails:",
"Try Again": "Probeer opnieuw",
"Need help?": "Heb je hulp nodig?",
"Contact Support": "Neem contact op met ondersteuning",
"Code Highlighting": "Code markering",
"Enable Highlighting": "Markering inschakelen",
"Code Language": "Code taal"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Zaloguj się za pomocą Apple",
"Sign in with GitHub": "Zaloguj się za pomocą GitHub",
"Account": "Konto",
"Cloud Storage": "Przestrzeń w chmurze",
"Failed to delete user. Please try again later.": "Nie udało się usunąć użytkownika. Spróbuj ponownie później.",
"Unlimited Offline/Online Reading": "Nieograniczone czytanie offline/online",
"Unlimited Cloud Sync Devices": "Nieograniczona liczba urządzeń synchronizowanych w chmurze",
@@ -348,5 +347,23 @@
"Never synced": "Brak synchronizacji",
"Show Remaining Time": "Pokazuj pozostały czas",
"Show Page Number": "Pokaż numer strony",
"{{time}} min left in chapter": "{{time}} min pozostało do końca rozdziału"
"{{time}} min left in chapter": "{{time}} min pozostało do końca rozdziału",
"Override Book Color": "Nadpisz kolor książki",
"Login Required": "Wymagane logowanie",
"Quota Exceeded": "Limit przekroczony",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% użytych znaków tłumaczenia dziennego.",
"Translation Characters": "Znaki tłumaczenia",
"Daily translation quota reached. Select another translate service to proceed.": "Przekroczono dzienny limit znaków tłumaczenia. Wybierz inną usługę tłumaczenia, aby kontynuować.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} głos",
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} głosy",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} głosów",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} głosu",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Coś poszło nie tak. Nie martw się, nasz zespół został powiadomiony i pracujemy nad naprawą.",
"Error Details:": "Informacje o błędzie:",
"Try Again": "Spróbuj ponownie",
"Need help?": "Potrzebujesz pomocy?",
"Contact Support": "Skontaktuj się z pomocą techniczną",
"Code Highlighting": "Podświetlanie kodu",
"Enable Highlighting": "Zezwól na podświetlanie",
"Code Language": "Język kodu"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Entrar com o Apple",
"Sign in with GitHub": "Entrar com o GitHub",
"Account": "Conta",
"Cloud Storage": "Armazenamento na nuvem",
"Failed to delete user. Please try again later.": "Falha ao excluir usuário. Por favor, tente novamente mais tarde.",
"Unlimited Offline/Online Reading": "Leitura offline/online ilimitada",
"Unlimited Cloud Sync Devices": "Sincronização ilimitada de dispositivos na nuvem",
@@ -346,5 +345,22 @@
"Never synced": "Nunca sincronizado",
"Show Remaining Time": "Mostrar Tempo Restante",
"Show Page Number": "Mostrar Número da Página",
"{{time}} min left in chapter": "{{time}} min restantes no capítulo"
"{{time}} min left in chapter": "{{time}} min restantes no capítulo",
"Override Book Color": "Substituir Cor do Livro",
"Login Required": "Login necessário",
"Quota Exceeded": "Limite excedido",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dos caracteres de tradução diários usados.",
"Translation Characters": "Caracteres de Tradução",
"Daily translation quota reached. Select another translate service to proceed.": "Quota diária de tradução atingida. Selecione outro serviço de tradução para continuar.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voz",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} vozes",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} vozes",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Algo deu errado. Não se preocupe, nossa equipe foi notificada e estamos trabalhando em uma correção.",
"Error Details:": "Detalhes do Erro:",
"Try Again": "Tentar Novamente",
"Need help?": "Precisa de ajuda?",
"Contact Support": "Contato com Suporte",
"Code Highlighting": "Realce de Código",
"Enable Highlighting": "Ativar Realce",
"Code Language": "Idioma do Código"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Войти через Apple",
"Sign in with GitHub": "Войти через GitHub",
"Account": "Аккаунт",
"Cloud Storage": "Облачное хранилище",
"Failed to delete user. Please try again later.": "Не удалось удалить пользователя. Пожалуйста, повторите попытку позже.",
"Unlimited Offline/Online Reading": "Безлимитное чтение офлайн/онлайн",
"Unlimited Cloud Sync Devices": "Неограниченное количество устройств для облачной синхронизации",
@@ -348,5 +347,23 @@
"Never synced": "Не синхр.",
"Show Remaining Time": "Показать оставшееся время",
"Show Page Number": "Показать номер страницы",
"{{time}} min left in chapter": "{{time}} мин осталось в главе"
"{{time}} min left in chapter": "{{time}} мин осталось в главе",
"Override Book Color": "Переопределить цвет книги",
"Login Required": "Требуется вход",
"Quota Exceeded": "Лимит превышен",
"{{percentage}}% of Daily Translation Characters Used.": "Использовано {{percentage}}% от суточного лимита символов перевода.",
"Translation Characters": "Символы перевода",
"Daily translation quota reached. Select another translate service to proceed.": "Достигнут суточный лимит символов перевода. Выберите другой сервис перевода для продолжения.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} голос",
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} голоса",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} голосов",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} голоса",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Что-то пошло не так. Не волнуйтесь, наша команда уведомлена и работает над исправлением.",
"Error Details:": "Подробности ошибки:",
"Try Again": "Попробовать снова",
"Need help?": "Нужна помощь?",
"Contact Support": "Связаться с поддержкой",
"Code Highlighting": "Подсветка кода",
"Enable Highlighting": "Включить подсветку",
"Code Language": "Язык кода"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Apple ile giriş yap",
"Sign in with GitHub": "GitHub ile giriş yap",
"Account": "Hesap",
"Cloud Storage": "Bulut Depolama",
"Failed to delete user. Please try again later.": "Kullanıcı silinemedi. Lütfen daha sonra tekrar deneyin.",
"Unlimited Offline/Online Reading": "Sınırsız Çevrimdışı/Çevrimiçi Okuma",
"Unlimited Cloud Sync Devices": "Sınırsız Bulut Senkronizasyon Cihazları",
@@ -344,5 +343,21 @@
"Never synced": "Hiç eşzlenmedi",
"Show Remaining Time": "Kalan Süreyi Göster",
"Show Page Number": "Sayfa Numarasını Göster",
"{{time}} min left in chapter": "{{time}} dakika bölümde kaldı"
"{{time}} min left in chapter": "{{time}} dakika bölümde kaldı",
"Override Book Color": "Kitap Rengini Geçersiz Kıl",
"Login Required": "Giriş gerekli",
"Quota Exceeded": "Kota aşıldı",
"{{percentage}}% of Daily Translation Characters Used.": "Çeviri için günlük karakterlerin %{{percentage}}'si kullanıldı.",
"Translation Characters": "Çeviri Karakterleri",
"Daily translation quota reached. Select another translate service to proceed.": "Günlük çeviri kotası aşıldı. Devam etmek için başka bir çeviri hizmeti seçin.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} ses",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} sesler",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Bir şeyler yanlış gitti. Endişelenmeyin, ekibimiz bilgilendirildi ve bir çözüm üzerinde çalışıyoruz.",
"Error Details:": "Error Detayları:",
"Try Again": "Tekrar Dene",
"Need help?": "Yardıma mı ihtiyacınız var?",
"Contact Support": "Destekle İletişime Geç",
"Code Highlighting": "Code Vurgulama",
"Enable Highlighting": "Vurgulamayı Etkinleştir",
"Code Language": "Code Dili"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Увійти через Apple",
"Sign in with GitHub": "Увійти через GitHub",
"Account": "Обліковий запис",
"Cloud Storage": "Хмарне сховище",
"Failed to delete user. Please try again later.": "Не вдалося видалити користувача. Будь ласка, спробуйте пізніше.",
"Unlimited Offline/Online Reading": "Необмежене читання офлайн/онлайн",
"Unlimited Cloud Sync Devices": "Необмежена кількість пристроїв для хмарної синхронізації",
@@ -348,5 +347,23 @@
"Never synced": "Ніколи не синхр.",
"Show Remaining Time": "Показати залишок часу",
"Show Page Number": "Показати номер сторінки",
"{{time}} min left in chapter": "{{time}} хв до кінця розділу"
"{{time}} min left in chapter": "{{time}} хв до кінця розділу",
"Override Book Color": "Перевизначити колір книги",
"Login Required": "Потрібен вхід",
"Quota Exceeded": "Перевищено ліміт",
"{{percentage}}% of Daily Translation Characters Used.": "Використано {{percentage}}% символів перекладу за день.",
"Translation Characters": "Символи перекладу",
"Daily translation quota reached. Select another translate service to proceed.": "Досягнуто добового ліміту перекладу. Виберіть іншу службу перекладу для продовження.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} голос",
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} голоси",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} голосів",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} голоси",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Щось пішло не так. Не хвилюйтеся, наша команда була сповіщена, і ми працюємо над виправленням.",
"Error Details:": "Деталі помилки:",
"Try Again": "Спробувати ще раз",
"Need help?": "Потрібна допомога?",
"Contact Support": "Зв'язатися з підтримкою",
"Code Highlighting": "Підсвічування коду",
"Enable Highlighting": "Увімкнути підсвічування",
"Code Language": "Мова коду"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "Đăng nhập với Apple",
"Sign in with GitHub": "Đăng nhập với GitHub",
"Account": "Tài khoản",
"Cloud Storage": "Lưu trữ đám mây",
"Failed to delete user. Please try again later.": "Không thể xóa người dùng. Vui lòng thử lại sau.",
"Unlimited Offline/Online Reading": "Đọc ngoại tuyến/trực tuyến không giới hạn",
"Unlimited Cloud Sync Devices": "Đồng bộ hóa thiết bị đám mây không giới hạn",
@@ -342,5 +341,20 @@
"Never synced": "Chưa từng đồng bộ",
"Show Remaining Time": "Hiển thị thời gian còn lại",
"Show Page Number": "Hiển thị số trang",
"{{time}} min left in chapter": "{{time}} phút còn lại trong chương"
"{{time}} min left in chapter": "{{time}} phút còn lại trong chương",
"Override Book Color": "Đổi màu sách",
"Login Required": "Cần đăng nhập",
"Quota Exceeded": "Vượt hạn mức",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% số ký tự dịch hàng ngày đã sử dụng.",
"Translation Characters": "Những ký tự dịch",
"Daily translation quota reached. Select another translate service to proceed.": "Đã đạt hạn mức dịch hàng ngày. Chọn dịch vụ dịch khác để tiếp tục.",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} giọng",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Đã xảy ra sự cố. Đừng lo, nhóm của chúng tôi đã được thông báo và chúng tôi đang làm việc để sửa chữa.",
"Error Details:": "Chi tiết lỗi:",
"Try Again": "Thử lại",
"Need help?": "Bạn cần giúp đỡ?",
"Contact Support": "Liên hệ hỗ trợ",
"Code Highlighting": "Đánh dấu mã",
"Enable Highlighting": "Cho phép đánh dấu",
"Code Language": "Ngôn ngữ mã"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "使用 Apple 登录",
"Sign in with GitHub": "使用 GitHub 登录",
"Account": "账户",
"Cloud Storage": "云存储",
"Failed to delete user. Please try again later.": "删除用户失败。请稍后重试。",
"Unlimited Offline/Online Reading": "无限离线和在线阅读",
"Unlimited Cloud Sync Devices": "无限云同步设备个数",
@@ -342,5 +341,20 @@
"Never synced": "从未同步",
"Show Remaining Time": "显示剩余时间",
"Show Page Number": "显示页码",
"{{time}} min left in chapter": "本章剩余 {{time}} 分钟"
"{{time}} min left in chapter": "本章剩余 {{time}} 分钟",
"Override Book Color": "覆盖书籍颜色",
"Login Required": "需登录",
"Quota Exceeded": "额度不足",
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻译字符数",
"Translation Characters": "翻译字数",
"Daily translation quota reached. Select another translate service to proceed.": "已达到每日翻译配额,请选择其他翻译服务继续。",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种声音",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "出了点问题。请不要担心,我们的团队已经收到通知并正在修复中。",
"Error Details:": "错误详情:",
"Try Again": "重试",
"Need help?": "需要帮助吗?",
"Contact Support": "联系客服",
"Code Highlighting": "代码高亮",
"Enable Highlighting": "启用高亮",
"Code Language": "代码语言"
}
@@ -186,7 +186,6 @@
"Sign in with Apple": "使用 Apple 登入",
"Sign in with GitHub": "使用 GitHub 登入",
"Account": "帳戶",
"Cloud Storage": "雲端儲存空間",
"Failed to delete user. Please try again later.": "刪除使用者失敗。請稍後再試。",
"Unlimited Offline/Online Reading": "無限離線/線上閱讀",
"Unlimited Cloud Sync Devices": "無限雲端同步裝置",
@@ -342,5 +341,20 @@
"Never synced": "從未同步",
"Show Remaining Time": "顯示剩餘時間",
"Show Page Number": "顯示頁碼",
"{{time}} min left in chapter": "本章剩餘 {{time}} 分鐘"
"{{time}} min left in chapter": "本章剩餘 {{time}} 分鐘",
"Override Book Color": "覆蓋書籍顏色",
"Login Required": "需登入",
"Quota Exceeded": "額度不足",
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻譯字數",
"Translation Characters": "翻譯字數",
"Daily translation quota reached. Select another translate service to proceed.": "已達到每日翻譯字數限制,請選擇其他翻譯服務繼續。",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 種語音",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "出了點問題。別擔心,我們的團隊已經收到通知,正在努力修復。",
"Error Details:": "錯誤詳情:",
"Try Again": "再試一次",
"Need help?": "需要幫助嗎?",
"Contact Support": "聯繫支援",
"Code Highlighting": "程式碼高亮",
"Enable Highlighting": "啟用高亮",
"Code Language": "程式語言"
}
+29
View File
@@ -1,5 +1,34 @@
{
"releases": {
"0.9.56": {
"date": "2025-06-13",
"notes": [
"Performance: Improved TOC loading for books with very large chapter lists",
"TTS: Added support for native Android TTS engines (e.g., MultiTTS)",
"TTS: Fixed an issue where the next chapter would not load properly in the background",
"Translation: Improved accuracy and clarity of some Arabic translations",
"Reading: Added support for code syntax highlighting in books with code snippets"
]
},
"0.9.55": {
"date": "2025-06-07",
"notes": [
"Sync: Notes deleted on other devices now correctly sync across all platforms",
"Layout: Now text justification can be unset in the layout settings",
"Translation: Added notification when daily translation quota is exceeded",
"Performance: Reduced unnecessary animations on Android to improve efficiency"
]
},
"0.9.53": {
"date": "2025-06-05",
"notes": [
"Reader: New option to customize book foreground and background colors",
"Reader: Fixed an issue where images appeared too small in some EPUBs",
"EPUB: Improved cover image detection for certain EPUB files",
"TTS: Improved background playback stability on iOS devices",
"Translation: Introduced a daily DeepL translation quota to ensure long-term availability"
]
},
"0.9.52": {
"date": "2025-06-03",
"notes": [
+1
View File
@@ -46,6 +46,7 @@ tauri-plugin-deep-link = "2.2.1"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.4"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
@@ -104,6 +104,7 @@
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
"native-bridge:default",
"native-tts:default",
"deep-link:default"
]
}
@@ -13,11 +13,13 @@
android:largeHeap="true"
android:label="@string/app_name"
android:theme="@style/Theme.readest"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:hardwareAccelerated="true"
android:name=".MainActivity"
android:exported="true">
@@ -0,0 +1,17 @@
/.vs
.DS_Store
.Thumbs.db
*.sublime*
.idea/
debug.log
package-lock.json
.vscode/settings.json
yarn.lock
/.tauri
/target
Cargo.lock
node_modules/
dist-js
dist
@@ -0,0 +1,19 @@
[package]
name = "tauri-plugin-native-tts"
version = "0.1.0"
authors = [ "You" ]
description = ""
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-native-tts"
[dependencies]
tauri = { version = "2.5.0" }
serde = "1.0"
thiserror = "2"
schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.2.0", features = ["build"] }
schemars = "0.8"
@@ -0,0 +1 @@
# Tauri Plugin native-tts
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -0,0 +1,44 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.readest.native_tts"
compileSdk = 34
defaultConfig {
minSdk = 21
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,31 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
google()
}
resolutionStrategy {
eachPlugin {
switch (requested.id.id) {
case "com.android.library":
useVersion("8.0.2")
break
case "org.jetbrains.kotlin.android":
useVersion("1.8.20")
break
}
}
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
google()
}
}
include ':tauri-android'
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
@@ -0,0 +1,24 @@
package com.readest.native_tts
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.readest.native_tts", appContext.packageName)
}
}
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,380 @@
package com.readest.native_tts
import android.os.Bundle
import android.app.Activity
import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.speech.tts.Voice
import android.util.Log
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.PluginResult
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.*
data class TTSVoiceData(
val id: String,
val name: String,
val lang: String,
val disabled: Boolean = false
)
data class TTSMessageEvent(
val code: String, // 'boundary' | 'error' | 'end'
val message: String? = null,
val mark: String? = null
)
@InvokeArg
class SpeakArgs(
val text: String? = "",
val preload: Boolean? = false
)
@InvokeArg
class SetRateArgs(
val rate: Float? = 1.0f
)
@InvokeArg
class SetPitchArgs(
val pitch: Float? = 1.0f
)
@InvokeArg
class SetVoiceArgs(
val voice: String? = null
)
@TauriPlugin
class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val TAG = "NativeTTSPlugin"
private const val CHANNEL_NAME = "tts_events"
}
private var textToSpeech: TextToSpeech? = null
private var isInitialized = AtomicBoolean(false)
private var isPaused = AtomicBoolean(false)
private var isSpeaking = AtomicBoolean(false)
private var currentRate = AtomicReference<Float>(1.0f)
private var currentPitch = AtomicReference<Float>(1.0f)
private val eventChannels = ConcurrentHashMap<String, Channel<TTSMessageEvent>>()
private val speakingJobs = ConcurrentHashMap<String, Job>()
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@Command
fun init(invoke: Invoke) {
coroutineScope.launch {
try {
val success = initializeTTS()
val result = JSObject().apply {
put("success", success)
}
invoke.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize TTS", e)
invoke.reject("Failed to initialize TTS: ${e.message}")
}
}
}
private suspend fun initializeTTS(): Boolean = suspendCancellableCoroutine { continuation ->
try {
textToSpeech = TextToSpeech(activity) { status ->
when (status) {
TextToSpeech.SUCCESS -> {
setupTTSListener()
isInitialized.set(true)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
continuation.resume(true) {}
}
else -> {
Log.e(TAG, "TTS initialization failed with status: $status")
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
continuation.resume(false) {}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception during TTS initialization", e)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
continuation.resume(false) {}
}
}
private fun setupTTSListener() {
textToSpeech?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
utteranceId?.let { id ->
isSpeaking.set(true)
sendEvent(id, TTSMessageEvent("boundary", "start"))
}
}
override fun onDone(utteranceId: String?) {
utteranceId?.let { id ->
isSpeaking.set(false)
sendEvent(id, TTSMessageEvent("end"))
closeEventChannel(id)
}
}
@Deprecated("deprecated in API level 21")
override fun onError(utteranceId: String?) {
utteranceId?.let { id ->
isSpeaking.set(false)
sendEvent(id, TTSMessageEvent("error", "TTS playback error"))
closeEventChannel(id)
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
utteranceId?.let { id ->
isSpeaking.set(false)
sendEvent(id, TTSMessageEvent("error", "TTS playback error:$errorCode"))
closeEventChannel(id)
}
}
override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
utteranceId?.let { id ->
sendEvent(id, TTSMessageEvent("boundary", "range", "pos:$start-$end"))
}
}
})
}
@Command
fun speak(invoke: Invoke) {
val args = invoke.parseArgs(SpeakArgs::class.java)
val text = args.text ?: ""
if (text.isEmpty()) {
invoke.reject("Text cannot be empty")
return
}
val utteranceId = UUID.randomUUID().toString()
coroutineScope.launch {
try {
val eventChannel = Channel<TTSMessageEvent>(Channel.UNLIMITED)
eventChannels[utteranceId] = eventChannel
val speakJob = launch {
speakText(text, utteranceId, args.preload ?: false)
}
speakingJobs[utteranceId] = speakJob
val result = JSObject().apply {
put("utteranceId", utteranceId)
}
invoke.resolve(result)
// Start sending events to the frontend
startEventStream(utteranceId)
} catch (e: Exception) {
Log.e(TAG, "Failed to start speaking", e)
invoke.reject("Failed to start speaking: ${e.message}")
}
}
}
private suspend fun speakText(text: String, utteranceId: String, preload: Boolean) {
withContext(Dispatchers.Main) {
try {
textToSpeech?.apply {
setSpeechRate(currentRate.get())
setPitch(currentPitch.get())
}
val params = Bundle().apply {
putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId)
}
val result = textToSpeech?.speak(
text,
if (preload) TextToSpeech.QUEUE_ADD else TextToSpeech.QUEUE_FLUSH,
params,
utteranceId
)
if (result != TextToSpeech.SUCCESS) {
sendEvent(utteranceId, TTSMessageEvent("error", "Failed to start speech"))
}
} catch (e: Exception) {
sendEvent(utteranceId, TTSMessageEvent("error", "Exception during speech: ${e.message}"))
}
}
}
private fun startEventStream(utteranceId: String) {
coroutineScope.launch {
val channel = eventChannels[utteranceId] ?: return@launch
try {
for (event in channel) {
val eventData = JSObject().apply {
put("utteranceId", utteranceId)
put("code", event.code)
event.message?.let { put("message", it) }
event.mark?.let { put("mark", it) }
}
trigger(CHANNEL_NAME, eventData)
}
} catch (e: Exception) {
Log.e(TAG, "Error in event stream for $utteranceId", e)
}
}
}
private fun sendEvent(utteranceId: String, event: TTSMessageEvent) {
coroutineScope.launch {
eventChannels[utteranceId]?.trySend(event)
}
}
private fun closeEventChannel(utteranceId: String) {
coroutineScope.launch {
eventChannels[utteranceId]?.close()
eventChannels.remove(utteranceId)
speakingJobs[utteranceId]?.cancel()
speakingJobs.remove(utteranceId)
}
}
@Command
fun pause(invoke: Invoke) {
try {
if (textToSpeech?.stop() == TextToSpeech.SUCCESS) {
isPaused.set(true)
invoke.resolve()
} else {
invoke.reject("Failed to pause TTS")
}
} catch (e: Exception) {
invoke.reject("Exception while pausing: ${e.message}")
}
}
@Command
fun resume(invoke: Invoke) {
try {
isPaused.set(false)
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Exception while resuming: ${e.message}")
}
}
@Command
fun stop(invoke: Invoke) {
try {
if (textToSpeech?.stop() == TextToSpeech.SUCCESS) {
isSpeaking.set(false)
isPaused.set(false)
speakingJobs.values.forEach { it.cancel() }
eventChannels.values.forEach { it.close() }
speakingJobs.clear()
eventChannels.clear()
invoke.resolve()
} else {
invoke.reject("Failed to stop TTS")
}
} catch (e: Exception) {
invoke.reject("Exception while stopping: ${e.message}")
}
}
@Command
fun set_rate(invoke: Invoke) {
val args = invoke.parseArgs(SetRateArgs::class.java)
try {
currentRate.set(args.rate)
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Exception setting rate: ${e.message}")
}
}
@Command
fun set_pitch(invoke: Invoke) {
val args = invoke.parseArgs(SetPitchArgs::class.java)
try {
currentPitch.set(args.pitch)
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Exception setting pitch: ${e.message}")
}
}
@Command
fun set_voice(invoke: Invoke) {
val args = invoke.parseArgs(SetVoiceArgs::class.java)
try {
val voices = textToSpeech?.voices
val targetVoice = voices?.find { it.name == args.voice }
if (targetVoice != null) {
val result = textToSpeech?.setVoice(targetVoice)
if (result == TextToSpeech.SUCCESS) {
invoke.resolve()
} else {
invoke.reject("Failed to set voice: ${args.voice}")
}
} else {
invoke.reject("Voice not found: ${args.voice}")
}
} catch (e: Exception) {
invoke.reject("Exception setting voice: ${e.message}")
}
}
@Command
fun get_all_voices(invoke: Invoke) {
try {
val voices = textToSpeech?.voices?.map { voice ->
JSObject().apply {
put("id", voice.name)
put("name", voice.name)
put("lang", voice.locale.toLanguageTag())
put("disabled", false)
}
} ?: emptyList()
val result = JSObject().apply {
put("voices", JSONArray(voices))
}
invoke.resolve(result)
} catch (e: Exception) {
invoke.reject("Exception getting voices: ${e.message}")
}
}
fun destroy() {
coroutineScope.cancel()
textToSpeech?.shutdown()
eventChannels.values.forEach { it.close() }
eventChannels.clear()
speakingJobs.values.forEach { it.cancel() }
speakingJobs.clear()
}
}
@@ -0,0 +1,17 @@
package com.readest.native-tts
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,20 @@
const COMMANDS: &[&str] = &[
"init",
"speak",
"stop",
"pause",
"resume",
"set_rate",
"set_pitch",
"set_voice",
"get_all_voices",
"registerListener",
"remove_listener",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -0,0 +1,10 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved
@@ -0,0 +1,32 @@
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "tauri-plugin-native-tts",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-tts",
type: .static,
targets: ["tauri-plugin-native-tts"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-tts",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
)
@@ -0,0 +1,3 @@
# Tauri Plugin native-tts
A description of this package.
@@ -0,0 +1,20 @@
import SwiftRs
import Tauri
import UIKit
import WebKit
class PingArgs: Decodable {
let value: String?
}
class NativeTTSPlugin: Plugin {
@objc public func ping(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(PingArgs.self)
invoke.resolve(["value": args.value ?? ""])
}
}
@_cdecl("init_plugin_native_tts")
func initPlugin() -> Plugin {
return NativeTTSPlugin()
}
@@ -0,0 +1,8 @@
import XCTest
@testable import ExamplePlugin
final class ExamplePluginTests: XCTestCase {
func testExample() throws {
let plugin = ExamplePlugin()
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-all-voices"
description = "Enables the get_all_voices command without any pre-configured scope."
commands.allow = ["get_all_voices"]
[[permission]]
identifier = "deny-get-all-voices"
description = "Denies the get_all_voices command without any pre-configured scope."
commands.deny = ["get_all_voices"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-init"
description = "Enables the init command without any pre-configured scope."
commands.allow = ["init"]
[[permission]]
identifier = "deny-init"
description = "Denies the init command without any pre-configured scope."
commands.deny = ["init"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pause"
description = "Enables the pause command without any pre-configured scope."
commands.allow = ["pause"]
[[permission]]
identifier = "deny-pause"
description = "Denies the pause command without any pre-configured scope."
commands.deny = ["pause"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-registerListener"
description = "Enables the registerListener command without any pre-configured scope."
commands.allow = ["registerListener"]
[[permission]]
identifier = "deny-registerListener"
description = "Denies the registerListener command without any pre-configured scope."
commands.deny = ["registerListener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-remove-listener"
description = "Enables the remove_listener command without any pre-configured scope."
commands.allow = ["remove_listener"]
[[permission]]
identifier = "deny-remove-listener"
description = "Denies the remove_listener command without any pre-configured scope."
commands.deny = ["remove_listener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-resume"
description = "Enables the resume command without any pre-configured scope."
commands.allow = ["resume"]
[[permission]]
identifier = "deny-resume"
description = "Denies the resume command without any pre-configured scope."
commands.deny = ["resume"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-pitch"
description = "Enables the set_pitch command without any pre-configured scope."
commands.allow = ["set_pitch"]
[[permission]]
identifier = "deny-set-pitch"
description = "Denies the set_pitch command without any pre-configured scope."
commands.deny = ["set_pitch"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-rate"
description = "Enables the set_rate command without any pre-configured scope."
commands.allow = ["set_rate"]
[[permission]]
identifier = "deny-set-rate"
description = "Denies the set_rate command without any pre-configured scope."
commands.deny = ["set_rate"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-voice"
description = "Enables the set_voice command without any pre-configured scope."
commands.allow = ["set_voice"]
[[permission]]
identifier = "deny-set-voice"
description = "Denies the set_voice command without any pre-configured scope."
commands.deny = ["set_voice"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-speak"
description = "Enables the speak command without any pre-configured scope."
commands.allow = ["speak"]
[[permission]]
identifier = "deny-speak"
description = "Denies the speak command without any pre-configured scope."
commands.deny = ["speak"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-stop"
description = "Enables the stop command without any pre-configured scope."
commands.allow = ["stop"]
[[permission]]
identifier = "deny-stop"
description = "Denies the stop command without any pre-configured scope."
commands.deny = ["stop"]
@@ -0,0 +1,313 @@
## Default Permission
Default permissions for the plugin
#### This default permission set includes the following:
- `allow-init`
- `allow-speak`
- `allow-stop`
- `allow-pause`
- `allow-resume`
- `allow-set-rate`
- `allow-set-pitch`
- `allow-set-voice`
- `allow-get-all-voices`
- `allow-registerListener`
- `allow-remove-listener`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`native-tts:allow-get-all-voices`
</td>
<td>
Enables the get_all_voices command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-get-all-voices`
</td>
<td>
Denies the get_all_voices command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-init`
</td>
<td>
Enables the init command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-init`
</td>
<td>
Denies the init command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-pause`
</td>
<td>
Enables the pause command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-pause`
</td>
<td>
Denies the pause command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-registerListener`
</td>
<td>
Enables the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-registerListener`
</td>
<td>
Denies the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-remove-listener`
</td>
<td>
Enables the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-remove-listener`
</td>
<td>
Denies the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-resume`
</td>
<td>
Enables the resume command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-resume`
</td>
<td>
Denies the resume command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-set-pitch`
</td>
<td>
Enables the set_pitch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-set-pitch`
</td>
<td>
Denies the set_pitch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-set-rate`
</td>
<td>
Enables the set_rate command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-set-rate`
</td>
<td>
Denies the set_rate command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-set-voice`
</td>
<td>
Enables the set_voice command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-set-voice`
</td>
<td>
Denies the set_voice command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-speak`
</td>
<td>
Enables the speak command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-speak`
</td>
<td>
Denies the speak command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-stop`
</td>
<td>
Enables the stop command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-stop`
</td>
<td>
Denies the stop command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,15 @@
[default]
description = "Default permissions for the plugin"
permissions = [
"allow-init",
"allow-speak",
"allow-stop",
"allow-pause",
"allow-resume",
"allow-set-rate",
"allow-set-pitch",
"allow-set-voice",
"allow-get-all-voices",
"allow-registerListener",
"allow-remove-listener",
]
@@ -0,0 +1,438 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the get_all_voices command without any pre-configured scope.",
"type": "string",
"const": "allow-get-all-voices",
"markdownDescription": "Enables the get_all_voices command without any pre-configured scope."
},
{
"description": "Denies the get_all_voices command without any pre-configured scope.",
"type": "string",
"const": "deny-get-all-voices",
"markdownDescription": "Denies the get_all_voices command without any pre-configured scope."
},
{
"description": "Enables the init command without any pre-configured scope.",
"type": "string",
"const": "allow-init",
"markdownDescription": "Enables the init command without any pre-configured scope."
},
{
"description": "Denies the init command without any pre-configured scope.",
"type": "string",
"const": "deny-init",
"markdownDescription": "Denies the init command without any pre-configured scope."
},
{
"description": "Enables the pause command without any pre-configured scope.",
"type": "string",
"const": "allow-pause",
"markdownDescription": "Enables the pause command without any pre-configured scope."
},
{
"description": "Denies the pause command without any pre-configured scope.",
"type": "string",
"const": "deny-pause",
"markdownDescription": "Denies the pause command without any pre-configured scope."
},
{
"description": "Enables the registerListener command without any pre-configured scope.",
"type": "string",
"const": "allow-registerListener",
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
},
{
"description": "Denies the registerListener command without any pre-configured scope.",
"type": "string",
"const": "deny-registerListener",
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the resume command without any pre-configured scope.",
"type": "string",
"const": "allow-resume",
"markdownDescription": "Enables the resume command without any pre-configured scope."
},
{
"description": "Denies the resume command without any pre-configured scope.",
"type": "string",
"const": "deny-resume",
"markdownDescription": "Denies the resume command without any pre-configured scope."
},
{
"description": "Enables the set_pitch command without any pre-configured scope.",
"type": "string",
"const": "allow-set-pitch",
"markdownDescription": "Enables the set_pitch command without any pre-configured scope."
},
{
"description": "Denies the set_pitch command without any pre-configured scope.",
"type": "string",
"const": "deny-set-pitch",
"markdownDescription": "Denies the set_pitch command without any pre-configured scope."
},
{
"description": "Enables the set_rate command without any pre-configured scope.",
"type": "string",
"const": "allow-set-rate",
"markdownDescription": "Enables the set_rate command without any pre-configured scope."
},
{
"description": "Denies the set_rate command without any pre-configured scope.",
"type": "string",
"const": "deny-set-rate",
"markdownDescription": "Denies the set_rate command without any pre-configured scope."
},
{
"description": "Enables the set_voice command without any pre-configured scope.",
"type": "string",
"const": "allow-set-voice",
"markdownDescription": "Enables the set_voice command without any pre-configured scope."
},
{
"description": "Denies the set_voice command without any pre-configured scope.",
"type": "string",
"const": "deny-set-voice",
"markdownDescription": "Denies the set_voice command without any pre-configured scope."
},
{
"description": "Enables the speak command without any pre-configured scope.",
"type": "string",
"const": "allow-speak",
"markdownDescription": "Enables the speak command without any pre-configured scope."
},
{
"description": "Denies the speak command without any pre-configured scope.",
"type": "string",
"const": "deny-speak",
"markdownDescription": "Denies the speak command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-registerListener`\n- `allow-remove-listener`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-registerListener`\n- `allow-remove-listener`"
}
]
}
}
}
@@ -0,0 +1,53 @@
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::NativeTtsExt;
use crate::Result;
#[command]
pub(crate) async fn init<R: Runtime>(app: AppHandle<R>) -> Result<InitResponse> {
app.native_tts().init()
}
#[command]
pub(crate) async fn speak<R: Runtime>(
app: AppHandle<R>,
payload: SpeakArgs,
) -> Result<SpeakResponse> {
app.native_tts().speak(payload)
}
#[command]
pub(crate) async fn pause<R: Runtime>(app: AppHandle<R>) -> Result<()> {
app.native_tts().pause()
}
#[command]
pub(crate) async fn resume<R: Runtime>(app: AppHandle<R>) -> Result<()> {
app.native_tts().resume()
}
#[command]
pub(crate) async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<()> {
app.native_tts().stop()
}
#[command]
pub(crate) async fn set_rate<R: Runtime>(app: AppHandle<R>, payload: SetRateArgs) -> Result<()> {
app.native_tts().set_rate(payload)
}
#[command]
pub(crate) async fn set_pitch<R: Runtime>(app: AppHandle<R>, payload: SetPitchArgs) -> Result<()> {
app.native_tts().set_pitch(payload)
}
#[command]
pub(crate) async fn set_voice<R: Runtime>(app: AppHandle<R>, payload: SetVoiceArgs) -> Result<()> {
app.native_tts().set_voice(payload)
}
#[command]
pub(crate) async fn get_all_voices<R: Runtime>(app: AppHandle<R>) -> Result<GetVoicesResponse> {
app.native_tts().get_all_voices()
}
@@ -0,0 +1,44 @@
use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<NativeTts<R>> {
Ok(NativeTts(app.clone()))
}
/// Access to the native-tts APIs.
pub struct NativeTts<R: Runtime>(AppHandle<R>);
impl<R: Runtime> NativeTts<R> {
pub fn init(&self) -> crate::Result<InitResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn speak(&self, _args: SpeakArgs) -> crate::Result<SpeakResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn pause(&self) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn resume(&self) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn stop(&self) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_rate(&self, _args: SetRateArgs) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_pitch(&self, _args: SetPitchArgs) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_voice(&self, _args: SetVoiceArgs) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_all_voices(&self) -> crate::Result<GetVoicesResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -0,0 +1,25 @@
use serde::{ser::Serializer, Serialize};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Unsupported platform for this plugin")]
UnsupportedPlatformError,
#[error("Native tts error: {0}")]
NativeTTSError(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[cfg(mobile)]
#[error(transparent)]
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
@@ -0,0 +1,58 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use models::*;
#[cfg(desktop)]
mod desktop;
#[cfg(mobile)]
mod mobile;
mod commands;
mod error;
mod models;
pub use error::{Error, Result};
#[cfg(desktop)]
use desktop::NativeTts;
#[cfg(mobile)]
use mobile::NativeTts;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the native-tts APIs.
pub trait NativeTtsExt<R: Runtime> {
fn native_tts(&self) -> &NativeTts<R>;
}
impl<R: Runtime, T: Manager<R>> crate::NativeTtsExt<R> for T {
fn native_tts(&self) -> &NativeTts<R> {
self.state::<NativeTts<R>>().inner()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("native-tts")
.invoke_handler(tauri::generate_handler![
commands::init,
commands::speak,
commands::stop,
commands::pause,
commands::resume,
commands::set_rate,
commands::set_pitch,
commands::set_voice,
commands::get_all_voices,
])
.setup(|app, api| {
#[cfg(mobile)]
let native_tts = mobile::init(app, api)?;
#[cfg(desktop)]
let native_tts = desktop::init(app, api)?;
app.manage(native_tts);
Ok(())
})
.build()
}
@@ -0,0 +1,89 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_native_tts);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<NativeTts<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("com.readest.native_tts", "NativeTTSPlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_native_tts)?;
Ok(NativeTts(handle))
}
/// Access to the native-tts APIs.
pub struct NativeTts<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> NativeTts<R> {
pub fn init(&self) -> crate::Result<InitResponse> {
self.0.run_mobile_plugin("init", ()).map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn speak(&self, payload: SpeakArgs) -> crate::Result<SpeakResponse> {
self.0
.run_mobile_plugin("speak", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn pause(&self) -> crate::Result<()> {
self.0.run_mobile_plugin("pause", ()).map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn resume(&self) -> crate::Result<()> {
self.0.run_mobile_plugin("resume", ()).map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn stop(&self) -> crate::Result<()> {
self.0.run_mobile_plugin("stop", ()).map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn set_rate(&self, payload: SetRateArgs) -> crate::Result<()> {
self.0
.run_mobile_plugin("set_rate", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn set_pitch(&self, payload: SetPitchArgs) -> crate::Result<()> {
self.0
.run_mobile_plugin("set_pitch", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn set_voice(&self, payload: SetVoiceArgs) -> crate::Result<()> {
self.0
.run_mobile_plugin("set_voice", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn get_all_voices(&self) -> crate::Result<GetVoicesResponse> {
self.0
.run_mobile_plugin("get_all_voices", ())
.map_err(Into::into)
}
}
@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TTSVoice {
pub id: String,
pub name: String,
pub lang: String,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TTSMessageEvent {
pub code: String, // 'boundary' | 'error' | 'end'
pub message: Option<String>,
pub mark: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InitResponse {
pub success: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpeakArgs {
pub text: String,
#[serde(default)]
pub preload: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpeakResponse {
pub utterance_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetRateArgs {
pub rate: f32,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetPitchArgs {
pub pitch: f32,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetVoiceArgs {
pub voice: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetVoicesResponse {
pub voices: Vec<TTSVoice>,
}
+1
View File
@@ -134,6 +134,7 @@ pub fn run() {
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_native_bridge::init())
.plugin(tauri_plugin_native_tts::init())
.plugin(tauri_plugin_fs::init());
#[cfg(desktop)]
+118
View File
@@ -0,0 +1,118 @@
'use client';
import posthog from 'posthog-js';
import { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { parseWebViewVersion } from '@/utils/ua';
interface ErrorPageProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ error, reset }: ErrorPageProps) {
const _ = useTranslation();
const { appService } = useEnv();
const [browserInfo, setBrowserInfo] = useState('');
useEffect(() => {
setBrowserInfo(parseWebViewVersion(appService));
posthog.captureException(error);
}, [error]);
const handleGoHome = () => {
window.location.href = '/library';
};
const handleGoBack = () => {
if (window.history.length > 1) {
window.history.back();
} else {
handleGoHome();
}
};
return (
<div className='hero bg-base-200 min-h-screen'>
<div className='hero-content text-center'>
<div className='max-w-2xl'>
<div className='mb-8'>
<div className='text-error animate-pulse text-8xl'></div>
</div>
<h1 className='text-base-content mb-4 text-5xl font-bold'>Oops!</h1>
<p className='text-base-content/70 mb-8 text-lg'>
{_(
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.",
)}
</p>
<div className='alert alert-error mb-8'>
<div className='flex-col items-start text-left'>
<h3 className='mb-2 font-bold'>{_('Error Details:')}</h3>
<p className='break-words font-mono text-sm'>{error.message}</p>
{browserInfo && <p className='mt-2 font-mono text-sm'>Browser: {browserInfo}</p>}
{error.stack && (
<p className='mt-2 break-words font-mono text-sm'>
{error.stack.split('\n').slice(0, 5).join('\n')}
</p>
)}
{error.digest && <p className='mt-2 text-xs opacity-70'>Error ID: {error.digest}</p>}
</div>
</div>
<div className='flex flex-col gap-4'>
<button onClick={reset} className='btn btn-primary btn-lg'>
<svg className='mr-2 h-5 w-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
/>
</svg>
{_('Try Again')}
</button>
<div className='flex gap-3'>
<button onClick={handleGoBack} className='btn btn-outline flex-1'>
<svg className='mr-2 h-4 w-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M10 19l-7-7m0 0l7-7m-7 7h18'
/>
</svg>
{_('Go Back')}
</button>
<button onClick={handleGoHome} className='btn btn-outline flex-1'>
<svg className='mr-2 h-4 w-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'
/>
</svg>
{_('Your Library')}
</button>
</div>
</div>
<div className='border-base-300 mt-8 border-t pt-6'>
<p className='text-base-content/60 text-sm'>
{_('Need help?')}{' '}
<a href='mailto:support@readest.com' className='link link-primary'>
{_('Contact Support')}
</a>
</p>
</div>
</div>
</div>
</div>
);
}
@@ -68,7 +68,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
);
const isImportingBook = useRef(false);
const { currentBookshelf, setCurrentBookshelf, setLibrary } = useLibraryStore();
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
@@ -234,7 +234,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
};
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const filteredBookshelfItems = currentBookshelf
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const filteredBookshelfItems = currentBookshelfItems
.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
@@ -114,6 +114,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
if (await appService?.isBookAvailable(book)) {
return true;
}
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { PiUserCircle } from 'react-icons/pi';
import { PiUserCircleCheck } from 'react-icons/pi';
@@ -13,14 +13,13 @@ import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useQuotaStats } from '@/hooks/useQuotaStats';
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';
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
import { QuotaType } from '@/types/user';
import UserAvatar from '@/components/UserAvatar';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -33,10 +32,9 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const { user } = useAuth();
const { themeMode, setThemeMode } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
@@ -48,6 +46,8 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
const iconSize = useResponsiveSize(16);
const { quotas } = useQuotaStats();
const showAboutReadest = () => {
setAboutDialogVisible(true);
setIsDropdownOpen?.(false);
@@ -143,22 +143,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsTelemetryEnabled(settings.telemetryEnabled);
};
useEffect(() => {
if (!user || !token) return;
const storagPlan = getStoragePlanData(token);
const storageQuota: QuotaType = {
name: _('Storage'),
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
}),
used: Math.round(storagPlan.usage / 1024 / 1024),
total: Math.round(storagPlan.quota / 1024 / 1024),
unit: 'MB',
};
setQuotas([storageQuota]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
const userFullName = user?.user_metadata?.['full_name'];
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
@@ -188,7 +172,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
}
>
<ul>
<Quota quotas={quotas} className='h-10 pl-3 pr-2' />
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
</ul>
</MenuItem>
@@ -1,10 +1,11 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useSync } from '@/hooks/useSync';
import { useLibraryStore } from '@/store/libraryStore';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { Book } from '@/types/book';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { debounce } from '@/utils/debounce';
export interface UseBooksSyncProps {
onSyncStart?: () => void;
@@ -38,9 +39,6 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const lastSyncTime = useRef<number>(0);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const getNewBooks = () => {
if (!user) return [];
const newBooks = library.filter(
@@ -49,26 +47,18 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
return newBooks;
};
useEffect(() => {
if (!user) return;
const now = Date.now();
const timeSinceLastSync = now - lastSyncTime.current;
if (timeSinceLastSync > SYNC_BOOKS_INTERVAL_SEC * 1000) {
lastSyncTime.current = now;
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleAutoSync = useCallback(
debounce(() => {
const newBooks = getNewBooks();
syncBooks(newBooks, 'both');
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastSyncTime.current = Date.now();
const newBooks = getNewBooks();
syncBooks(newBooks, 'both');
syncTimeoutRef.current = null;
},
SYNC_BOOKS_INTERVAL_SEC * 1000 - timeSinceLastSync,
);
}
}, SYNC_BOOKS_INTERVAL_SEC * 1000),
[library, lastSyncedAtBooks],
);
useEffect(() => {
if (!user) return;
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [library]);
+8 -1
View File
@@ -218,6 +218,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageRef.current]);
useEffect(() => {
if (!libraryBooks.some((book) => !book.deletedAt)) {
handleSetSelectMode(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryBooks]);
const processOpenWithFiles = React.useCallback(
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const settings = await appService.loadSettings();
@@ -366,7 +373,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
for (const file of files) {
try {
const book = await appService?.importBook(file, library);
setLibrary(library);
setLibrary([...library]);
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book);
@@ -32,6 +32,7 @@ import { isCJKLang } from '@/utils/lang';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -116,6 +117,11 @@ const FoliateViewer: React.FC<{
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
// only call on load if we have highlighting turned on.
if (viewSettings.codeHighlighting) {
manageSyntaxHighlighting(detail.doc, viewSettings);
}
if (!detail.doc.isEventListenersAdded) {
// listened events in iframes are posted to the main window
// and then used by useMouseEvent and useTouchEvent
@@ -203,6 +209,12 @@ const FoliateViewer: React.FC<{
const { book } = view;
book.transformTarget?.addEventListener('load', (event: Event) => {
const { detail } = event as CustomEvent;
if (detail.isScript) {
detail.allowScript = viewSettings.allowScript ?? false;
}
});
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
@@ -108,8 +108,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
};
const handleSpeakText = async () => {
if (!view || !progress) return;
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
if (!view || !progress || !viewState) return;
if (viewState.ttsEnabled) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey });
@@ -6,7 +6,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATED_LANGS } from '@/services/constants';
import { UseTranslatorOptions } from '@/services/translators';
import { UseTranslatorOptions, getTranslators } from '@/services/translators';
import { localeToLang } from '@/utils/lang';
import Select from '@/components/Select';
@@ -76,7 +76,12 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
};
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedTranslator = translators.find((t) => t.name === event.target.value);
const requestedProvider = event.target.value;
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const selectedTranslator =
availableTranslators.find((t) => t.name === requestedProvider) || availableTranslators[0]!;
if (selectedTranslator) {
settings.globalReadSettings.translationProvider = selectedTranslator.name;
setSettings(settings);
@@ -86,9 +91,16 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
useEffect(() => {
const availableProviders = translators.map((t) => {
return { name: t.name, label: t.label };
let label = t.label;
if (t.authRequired && !token) {
label = `${label} (${_('Login Required')})`;
} else if (t.quotaExceeded) {
label = `${label} (${_('Quota Exceeded')})`;
}
return { name: t.name, label };
});
setProviders(availableProviders);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translators]);
useEffect(() => {
@@ -142,6 +154,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-sm font-normal'>{_('Original Text')}</h1>
<Select
className='bg-gray-600 text-white/75'
value={sourceLang}
onChange={handleSourceLangChange}
options={[
@@ -168,6 +181,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
<Select
className='bg-gray-600 text-white/75'
value={targetLang}
onChange={handleTargetLangChange}
options={Object.entries(TRANSLATOR_LANGS)
@@ -201,6 +215,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
)}
<div className='ml-auto'>
<Select
className='bg-gray-600 text-white/75'
value={provider}
onChange={handleProviderChange}
options={providers.map(({ name: value, label }) => ({ value, label }))}
@@ -19,6 +19,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { CODE_LANGUAGES, CodeLanguage, manageSyntaxHighlighting } from '@/utils/highlightjs';
import Select from '@/components/Select';
import ThemeEditor from './ThemeEditor';
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
@@ -27,7 +29,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useThemeStore();
const { envConfig } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const { getView, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
viewSettings.invertImgColorInDark,
@@ -36,8 +38,11 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const iconSize16 = useResponsiveSize(16);
const iconSize24 = useResponsiveSize(24);
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
const [customThems, setCustomThemes] = useState<Theme[]>([]);
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
const [overrideColor, setOverrideColor] = useState(viewSettings.overrideColor!);
const [codeHighlighting, setcodeHighlighting] = useState(viewSettings.codeHighlighting!);
const [codeLanguage, setCodeLanguage] = useState(viewSettings.codeLanguage!);
useEffect(() => {
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
@@ -45,6 +50,30 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
useEffect(() => {
if (overrideColor === viewSettings.overrideColor) return;
saveViewSettings(envConfig, bookKey, 'overrideColor', overrideColor);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideColor]);
useEffect(() => {
let update = false; // check if we need to update syntax highlighting
if (codeHighlighting !== viewSettings.codeHighlighting) {
saveViewSettings(envConfig, bookKey, 'codeHighlighting', codeHighlighting);
update = true;
}
if (codeLanguage !== viewSettings.codeLanguage) {
saveViewSettings(envConfig, bookKey, 'codeLanguage', codeLanguage);
update = true;
}
if (!update) return;
const view = getView(bookKey);
if (!view) return;
const docs = view.renderer.getContents();
docs.forEach(({ doc }) => manageSyntaxHighlighting(doc, viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeHighlighting, codeLanguage]);
useEffect(() => {
const customThemes = settings.globalReadSettings.customThemes ?? [];
setCustomThemes(
@@ -137,10 +166,20 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
</div>
<div className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Color')}</h2>
<input
type='checkbox'
className='toggle'
checked={overrideColor}
onChange={() => setOverrideColor(!overrideColor)}
/>
</div>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.concat(customThems).map(({ name, label, colors, isCustomizale }) => (
{themes.concat(customThemes).map(({ name, label, colors, isCustomizale }) => (
<label
key={name}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
@@ -183,6 +222,36 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</label>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Code Highlighting')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span className=''>{_('Enable Highlighting')}</span>
<input
type='checkbox'
className='toggle'
checked={codeHighlighting}
onChange={() => setcodeHighlighting(!codeHighlighting)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Code Language')}</span>
<Select
value={codeLanguage}
onChange={(event) => setCodeLanguage(event.target.value as CodeLanguage)}
options={CODE_LANGUAGES.map((lang) => ({
value: lang,
label: lang,
}))}
disabled={!codeHighlighting}
/>
</div>
</div>
</div>
</div>
</>
)}
</div>
@@ -2,15 +2,17 @@ import clsx from 'clsx';
import i18n from 'i18next';
import React, { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { getTranslators } from '@/services/translators';
import { TRANSLATED_LANGS } from '@/services/constants';
import DropDown from './DropDown';
import Select from '@/components/Select';
const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { token } = useAuth();
const { envConfig } = useEnv();
const { getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
@@ -22,7 +24,7 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const getCurrentUILangOption = () => {
const uiLanguage = viewSettings.uiLanguage;
return {
option: uiLanguage,
value: uiLanguage,
label:
uiLanguage === ''
? _('Auto')
@@ -32,13 +34,14 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const getLangOptions = () => {
const langs = TRANSLATED_LANGS as Record<string, string>;
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
const options = Object.entries(langs).map(([value, label]) => ({ value, label }));
options.sort((a, b) => a.label.localeCompare(b.label));
options.unshift({ option: '', label: _('System Language') });
options.unshift({ value: '', label: _('System Language') });
return options;
};
const handleSelectUILang = (option: string) => {
const handleSelectUILang = (event: React.ChangeEvent<HTMLSelectElement>) => {
const option = event.target.value;
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
i18n.changeLanguage(option ? option : navigator.language);
};
@@ -46,18 +49,31 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const getTranslationProviderOptions = () => {
const translators = getTranslators();
const availableProviders = translators.map((t) => {
return { option: t.name, label: t.label };
let label = t.label;
if (t.authRequired && !token) {
label = `${label} (${_('Login Required')})`;
} else if (t.quotaExceeded) {
label = `${label} (${_('Quota Exceeded')})`;
}
return { value: t.name, label };
});
return availableProviders;
};
const getCurrentTranslationProviderOption = () => {
const option = translationProvider;
const availableProviders = getTranslationProviderOptions();
return availableProviders.find((p) => p.option === option) || availableProviders[0]!;
const value = translationProvider;
const allProviders = getTranslationProviderOptions();
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const currentProvider = availableTranslators.find((t) => t.name === value)
? value
: availableTranslators[0]?.name;
return allProviders.find((p) => p.value === currentProvider) || allProviders[0]!;
};
const handleSelectTranslationProvider = (option: string) => {
const handleSelectTranslationProvider = (event: React.ChangeEvent<HTMLSelectElement>) => {
const option = event.target.value;
setTranslationProvider(option);
saveViewSettings(envConfig, bookKey, 'translationProvider', option, false, false);
viewSettings.translationProvider = option;
@@ -65,12 +81,13 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
const getCurrentTargetLangOption = () => {
const option = translateTargetLang;
const value = translateTargetLang;
const availableOptions = getLangOptions();
return availableOptions.find((o) => o.option === option) || availableOptions[0]!;
return availableOptions.find((o) => o.value === value) || availableOptions[0]!;
};
const handleSelectTargetLang = (option: string) => {
const handleSelectTargetLang = (event: React.ChangeEvent<HTMLSelectElement>) => {
const option = event.target.value;
setTranslateTargetLang(option);
saveViewSettings(envConfig, bookKey, 'translateTargetLang', option, false, false);
viewSettings.translateTargetLang = option;
@@ -93,12 +110,10 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Interface Language')}</span>
<DropDown
<Select
value={getCurrentUILangOption().value}
onChange={handleSelectUILang}
options={getLangOptions()}
selected={getCurrentUILangOption()}
onSelect={handleSelectUILang}
className='dropdown-bottom'
listClassName='!max-h-60'
/>
</div>
</div>
@@ -121,24 +136,21 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='config-item'>
<span className=''>{_('Translation Service')}</span>
<DropDown
selected={getCurrentTranslationProviderOption()}
<Select
value={getCurrentTranslationProviderOption().value}
onChange={handleSelectTranslationProvider}
options={getTranslationProviderOptions()}
onSelect={handleSelectTranslationProvider}
disabled={!translationEnabled}
className='dropdown-top'
/>
</div>
<div className='config-item'>
<span className=''>{_('Translate To')}</span>
<DropDown
<Select
value={getCurrentTargetLangOption().value}
onChange={handleSelectTargetLang}
options={getLangOptions()}
selected={getCurrentTargetLangOption()}
onSelect={handleSelectTargetLang}
disabled={!translationEnabled}
className='dropdown-top'
listClassName='!max-h-60'
/>
</div>
</div>
@@ -70,14 +70,14 @@ const SidebarContent: React.FC<{
<>
<div
className={clsx(
'sidebar-content flex min-h-0 flex-grow flex-col shadow-inner',
'sidebar-content flex h-full min-h-0 flex-grow flex-col shadow-inner',
'font-sans text-base font-normal sm:text-sm',
)}
>
<div
ref={scrollContainerRef}
className={clsx(
'scroll-container overflow-y-auto transition-opacity duration-300 ease-in-out',
'scroll-container min-h-0 flex-1 overflow-y-auto transition-opacity duration-300 ease-in-out',
{ 'opacity-0': fade, 'opacity-100': !fade },
)}
>
@@ -0,0 +1,176 @@
import clsx from 'clsx';
import React, { useCallback } from 'react';
import { ListChildComponentProps } from 'react-window';
import { TOCItem } from '@/libs/document';
import { getContentMd5 } from '@/utils/misc';
const createExpanderIcon = (isExpanded: boolean) => {
return (
<svg
viewBox='0 0 8 10'
width='8'
height='10'
className={clsx(
'text-base-content transform transition-transform',
isExpanded ? 'rotate-90' : 'rotate-0',
)}
style={{ transformOrigin: 'center' }}
fill='currentColor'
>
<polygon points='0 0, 8 5, 0 10' />
</svg>
);
};
export interface FlatTOCItem {
item: TOCItem;
depth: number;
index: number;
isExpanded?: boolean;
}
const TOCItemView = React.memo<{
bookKey: string;
flatItem: FlatTOCItem;
itemSize?: number;
isActive: boolean;
onToggleExpand: (item: TOCItem) => void;
onItemClick: (item: TOCItem) => void;
}>(({ flatItem, itemSize, isActive, onToggleExpand, onItemClick }) => {
const { item, depth } = flatItem;
const handleToggleExpand = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onToggleExpand(item);
},
[item, onToggleExpand],
);
const handleClickItem = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
onItemClick(item);
},
[item, onItemClick],
);
return (
<span
role='treeitem'
tabIndex={-1}
onClick={item.href ? handleClickItem : undefined}
aria-expanded={flatItem.isExpanded ? 'true' : 'false'}
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href ? getContentMd5(item.href) : undefined}
className={clsx(
'flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2',
isActive
? 'sm:bg-base-300/85 sm:hover:bg-base-300 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/85',
)}
style={{
height: itemSize ? `${itemSize}px` : 'auto',
paddingInlineStart: `${(depth + 1) * 12}px`,
}}
>
{item.subitems && (
<span
onClick={handleToggleExpand}
className='inline-block cursor-pointer'
style={{
padding: '12px',
margin: '-12px',
}}
>
{createExpanderIcon(flatItem.isExpanded || false)}
</span>
)}
<span
className='ms-2 truncate text-ellipsis'
style={{
maxWidth: 'calc(100% - 24px)',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{item.label}
</span>
{item.location && (
<span className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
{item.location.current + 1}
</span>
)}
</span>
);
});
TOCItemView.displayName = 'TOCItemView';
interface ListRowProps {
bookKey: string;
flatItem: FlatTOCItem;
itemSize?: number;
activeHref: string | null;
onToggleExpand: (item: TOCItem) => void;
onItemClick: (item: TOCItem) => void;
}
export const StaticListRow: React.FC<ListRowProps> = ({
bookKey,
flatItem,
itemSize,
activeHref,
onToggleExpand,
onItemClick,
}) => {
const isActive = activeHref === flatItem.item.href;
return (
<div
className={clsx(
'border-base-300 w-full border-b sm:border-none',
'pe-4 ps-2 pt-[1px] sm:pe-2',
)}
>
<TOCItemView
bookKey={bookKey}
flatItem={flatItem}
itemSize={itemSize}
isActive={isActive}
onToggleExpand={onToggleExpand}
onItemClick={onItemClick}
/>
</div>
);
};
export const VirtualListRow: React.FC<
ListChildComponentProps & {
data: {
bookKey: string;
flatItems: FlatTOCItem[];
itemSize: number;
activeHref: string | null;
onToggleExpand: (item: TOCItem) => void;
onItemClick: (item: TOCItem) => void;
};
}
> = ({ index, style, data }) => {
const { flatItems, bookKey, activeHref, itemSize, onToggleExpand, onItemClick } = data;
const flatItem = flatItems[index];
return (
<div style={style}>
<StaticListRow
bookKey={bookKey}
flatItem={flatItem}
itemSize={itemSize - 1}
activeHref={activeHref}
onToggleExpand={onToggleExpand}
onItemClick={onItemClick}
/>
</div>
);
};
@@ -1,198 +1,200 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FixedSizeList as VirtualList } from 'react-window';
import { TOCItem } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { findParentPath } from '@/utils/toc';
import { getContentMd5 } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { BookProgress } from '@/types/book';
import { getContentMd5 } from '@/utils/misc';
import { useTextTranslation } from '../../hooks/useTextTranslation';
import { FlatTOCItem, StaticListRow, VirtualListRow } from './TOCItem';
const createExpanderIcon = (isExpanded: boolean) => {
return (
<svg
viewBox='0 0 8 10'
width='8'
height='10'
className={clsx(
'text-base-content transform transition-transform',
isExpanded ? 'rotate-90' : 'rotate-0',
)}
style={{ transformOrigin: 'center' }}
fill='currentColor'
>
<polygon points='0 0, 8 5, 0 10' />
</svg>
);
};
const useFlattenedTOC = (toc: TOCItem[], expandedItems: Set<string>) => {
return useMemo(() => {
const flattenTOC = (items: TOCItem[], depth = 0): FlatTOCItem[] => {
const result: FlatTOCItem[] = [];
items.forEach((item, index) => {
const isExpanded = expandedItems.has(item.href || '');
result.push({ item, depth, index, isExpanded });
if (item.subitems && isExpanded) {
result.push(...flattenTOC(item.subitems, depth + 1));
}
});
return result;
};
const TOCItemView: React.FC<{
bookKey: string;
item: TOCItem;
depth: number;
expandedItems: string[];
}> = ({ bookKey, item, depth, expandedItems }) => {
const [isExpanded, setIsExpanded] = useState(expandedItems.includes(item.href || ''));
const { getView, getProgress } = useReaderStore();
const progress = getProgress(bookKey);
const handleToggleExpand = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleClickItem = (event: React.MouseEvent) => {
event.preventDefault();
eventDispatcher.dispatch('navigate', { bookKey, href: item.href });
if (item.href) {
getView(bookKey)?.goTo(item.href);
}
};
const isActive = progress ? progress.sectionHref === item.href : false;
useEffect(() => {
setIsExpanded(expandedItems.includes(item.href || ''));
}, [expandedItems, item.href]);
return (
<li className='border-base-300 w-full border-b sm:border-none sm:pt-[1px]'>
<span
role='treeitem'
tabIndex={-1}
onClick={item.href ? handleClickItem : undefined}
style={{ paddingInlineStart: `${(depth + 1) * 12}px` }}
aria-expanded={isExpanded ? 'true' : 'false'}
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href ? getContentMd5(item.href) : undefined}
className={`flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2 ${
isActive
? 'sm:bg-base-300/85 sm:hover:bg-base-300 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/85'
}`}
>
{item.subitems && (
<span
onClick={handleToggleExpand}
className='inline-block cursor-pointer'
style={{
padding: '12px',
margin: '-12px',
}}
>
{createExpanderIcon(isExpanded)}
</span>
)}
<span
className='ms-2 truncate text-ellipsis'
style={{
maxWidth: 'calc(100% - 24px)',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{item.label}
</span>
{item.location && (
<span className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
{item.location.current + 1}
</span>
)}
</span>
{item.subitems && isExpanded && (
<ol role='group'>
{item.subitems.map((subitem, index) => (
<TOCItemView
bookKey={bookKey}
key={`${index}-${subitem.href}`}
item={subitem}
depth={depth + 1}
expandedItems={expandedItems}
/>
))}
</ol>
)}
</li>
);
return flattenTOC(toc);
}, [toc, expandedItems]);
};
const TOCView: React.FC<{
bookKey: string;
toc: TOCItem[];
}> = ({ bookKey, toc }) => {
const { getProgress } = useReaderStore();
const { getView, getProgress, getViewState, getViewSettings } = useReaderStore();
const { sideBarBookKey, isSideBarVisible } = useSidebarStore();
const viewSettings = getViewSettings(bookKey)!;
const progress = getProgress(bookKey);
const viewState = getViewState(bookKey);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const viewRef = useRef<HTMLUListElement | null>(null);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [containerHeight, setContainerHeight] = useState(400);
useTextTranslation(bookKey, viewRef.current);
const containerRef = useRef<HTMLDivElement | null>(null);
const vitualListRef = useRef<VirtualList | null>(null);
const staticListRef = useRef<HTMLDivElement | null>(null);
const expandParents = (toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
setExpandedItems(parentPath.filter(Boolean) as string[]);
};
const scrollToProgress = (progress: BookProgress) => {
const { sectionHref: currentHref } = progress;
const hrefMd5 = currentHref ? getContentMd5(currentHref) : '';
const currentItem = viewRef.current?.querySelector(`[data-href="${hrefMd5}"]`);
if (currentItem) {
const rect = currentItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(currentItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
};
useTextTranslation(bookKey, containerRef.current);
useEffect(() => {
const observer = new MutationObserver(() => {
const progress = getProgress(bookKey);
if (progress && viewRef.current) {
scrollToProgress(progress);
observer.disconnect();
const updateHeight = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const parentContainer = containerRef.current.closest('.scroll-container');
if (parentContainer) {
const parentRect = parentContainer.getBoundingClientRect();
const availableHeight = parentRect.height - (rect.top - parentRect.top);
setContainerHeight(Math.max(400, availableHeight));
}
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
let resizeObserver: ResizeObserver | null = null;
if (containerRef.current) {
const parentContainer = containerRef.current.closest('.scroll-container');
if (parentContainer) {
resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(parentContainer);
}
}
return () => {
window.removeEventListener('resize', updateHeight);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, []);
const activeHref = useMemo(() => progress?.sectionHref || null, [progress]);
const flatItems = useFlattenedTOC(toc, expandedItems);
const handleToggleExpand = useCallback((item: TOCItem) => {
const href = item.href || '';
setExpandedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(href)) {
newSet.delete(href);
} else {
newSet.add(href);
}
return newSet;
});
}, []);
if (viewRef.current) {
observer.observe(viewRef.current, { childList: true, subtree: true });
const handleItemClick = useCallback(
(item: TOCItem) => {
eventDispatcher.dispatch('navigate', { bookKey, href: item.href });
if (item.href) {
getView(bookKey)?.goTo(item.href);
}
},
[bookKey, getView],
);
const expandParents = useCallback((toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
const parentHrefs = parentPath.filter(Boolean) as string[];
setExpandedItems(new Set(parentHrefs));
}, []);
const scrollToActiveItem = useCallback(() => {
if (!activeHref) return;
if (vitualListRef.current) {
const activeIndex = flatItems.findIndex((flatItem) => flatItem.item.href === activeHref);
if (activeIndex !== -1) {
vitualListRef.current.scrollToItem(activeIndex, 'center');
}
}
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewRef.current]);
if (staticListRef.current) {
const hrefMd5 = activeHref ? getContentMd5(activeHref) : '';
const activeItem = staticListRef.current?.querySelector(`[data-href="${hrefMd5}"]`);
if (activeItem) {
const rect = activeItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(activeItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
(activeItem as HTMLElement).setAttribute('aria-current', 'page');
}
}
}, [activeHref, flatItems]);
const virtualItemSize = useMemo(() => {
return window.innerWidth >= 640 && !viewSettings?.translationEnabled ? 37 : 57;
}, [viewSettings]);
const virtualListData = useMemo(
() => ({
flatItems,
itemSize: virtualItemSize,
bookKey,
activeHref,
onToggleExpand: handleToggleExpand,
onItemClick: handleItemClick,
}),
[flatItems, virtualItemSize, bookKey, activeHref, handleToggleExpand, handleItemClick],
);
useEffect(() => {
if (!progress || eventDispatcher.dispatchSync('tts-is-speaking')) return;
if (!progress || viewState?.ttsEnabled) return;
if (sideBarBookKey !== bookKey) return;
if (!isSideBarVisible) return;
const { sectionHref: currentHref } = progress;
if (currentHref) {
expandParents(toc, currentHref);
}
scrollToProgress(progress);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toc, progress, sideBarBookKey, isSideBarVisible]);
}, [toc, progress, viewState, sideBarBookKey, isSideBarVisible, bookKey, expandParents]);
useEffect(() => {
if (flatItems.length > 0) {
setTimeout(scrollToActiveItem, 0);
}
}, [flatItems, scrollToActiveItem]);
return (
<div className='rounded pt-2'>
<ul role='tree' ref={viewRef} className='pe-4 ps-2 sm:pe-2'>
{toc &&
toc.map((item, index) => (
<TOCItemView
<div className='rounded' ref={containerRef}>
{flatItems.length > 256 ? (
<VirtualList
ref={vitualListRef}
width='100%'
height={containerHeight}
itemCount={flatItems.length}
itemSize={virtualItemSize}
itemData={virtualListData}
overscanCount={20}
>
{VirtualListRow}
</VirtualList>
) : (
<div className='pt-2' ref={staticListRef}>
{flatItems.map((flatItem, index) => (
<StaticListRow
key={`static-row-${index}`}
bookKey={bookKey}
key={`${index}-${item.href}`}
item={item}
depth={0}
expandedItems={expandedItems}
flatItem={flatItem}
activeHref={activeHref}
onToggleExpand={handleToggleExpand}
onItemClick={handleItemClick}
/>
))}
</ul>
</div>
)}
</div>
);
};
@@ -9,7 +9,6 @@ import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { CFI } from '@/libs/document';
@@ -57,6 +56,16 @@ const TTSControl = () => {
if (unblockerAudioRef.current) return;
unblockerAudioRef.current = document.createElement('audio');
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
unblockerAudioRef.current.addEventListener('play', () => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = null;
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.setActionHandler('stop', null);
navigator.mediaSession.setActionHandler('seekbackward', null);
navigator.mediaSession.setActionHandler('seekforward', null);
}
});
unblockerAudioRef.current.preload = 'auto';
unblockerAudioRef.current.loop = true;
unblockerAudioRef.current.src = SILENCE_DATA;
@@ -81,7 +90,7 @@ const TTSControl = () => {
useEffect(() => {
return () => {
if (ttsControllerRef.current) {
ttsControllerRef.current.kill();
ttsControllerRef.current.shutdown();
ttsControllerRef.current = null;
}
};
@@ -90,11 +99,9 @@ const TTSControl = () => {
useEffect(() => {
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
eventDispatcher.onSync('tts-is-speaking', handleQueryIsSpeaking);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
eventDispatcher.offSync('tts-is-speaking', handleQueryIsSpeaking);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -189,11 +196,11 @@ const TTSControl = () => {
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: true });
}
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
if (appService?.isMobile) {
unblockAudio();
}
setTtsClientsInitialized(false);
const ttsController = new TTSController(view);
const ttsController = new TTSController(appService, view);
await ttsController.init();
await ttsController.initViewTTS();
const ssml = view.tts?.from(ttsFromRange);
@@ -229,10 +236,6 @@ const TTSControl = () => {
}
};
const handleQueryIsSpeaking = () => {
return !!ttsControllerRef.current;
};
const handleTogglePlay = async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
@@ -282,7 +285,7 @@ const TTSControl = () => {
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
if (appService?.isMobile) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
@@ -3,7 +3,7 @@ import React, { useState, ChangeEvent, useEffect } from 'react';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoice } from '@/services/tts';
import { TTSVoicesGroup } from '@/services/tts';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
@@ -20,7 +20,7 @@ type TTSPanelProps = {
onBackward: () => void;
onForward: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onGetVoices: (lang: string) => Promise<TTSVoicesGroup[]>;
onSetVoice: (voice: string, lang: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (bookKey: string, value: number) => void;
@@ -119,7 +119,7 @@ const TTSPanel = ({
const { settings, setSettings, saveSettings } = useSettingsStore();
const viewSettings = getViewSettings(bookKey);
const [voices, setVoices] = useState<TTSVoice[]>([]);
const [voiceGroups, setVoiceGroups] = useState<TTSVoicesGroup[]>([]);
const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0);
const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? '');
@@ -177,8 +177,8 @@ const TTSPanel = ({
useEffect(() => {
const fetchVoices = async () => {
const voices = await onGetVoices(ttsLang);
setVoices(voices);
const voiceGroups = await onGetVoices(ttsLang);
setVoiceGroups(voiceGroups);
};
fetchVoices();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -261,7 +261,12 @@ const TTSPanel = ({
onClick={() => onSelectTimeout(bookKey, option.value)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
<span
style={{
width: `${defaultIconSize}px`,
height: `${defaultIconSize}px`,
}}
>
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
</span>
<span className={clsx('text-base sm:text-sm')}>{option.label}</span>
@@ -282,21 +287,52 @@ const TTSPanel = ({
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
)}
>
{voices.map((voice, index) => (
<li
key={`${index}-${voice.id}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
</span>
<span className={clsx('text-base sm:text-sm', voice.disabled && 'text-gray-400')}>
{voice.name}
</span>
</div>
</li>
))}
{voiceGroups.map((voiceGroup, index) => {
return (
voiceGroup.voices.length > 0 && (
<div key={voiceGroup.id} className=''>
<div className='flex items-center gap-2 px-2 py-1'>
<span
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
></span>
<span className='text-sm text-gray-400 sm:text-xs'>
{_('{{engine}}: {{count}} voices', {
engine: _(voiceGroup.name),
count: voiceGroup.voices.length,
})}
</span>
</div>
{voiceGroup.voices.map((voice, voiceIndex) => (
<li
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
>
<div className='flex items-center px-2'>
<span
style={{
width: `${defaultIconSize}px`,
height: `${defaultIconSize}px`,
}}
>
{selectedVoice === voice.id && (
<MdCheck className='text-base-content' />
)}
</span>
<span
className={clsx(
'text-base sm:text-sm',
voice.disabled && 'text-gray-400',
)}
>
{_(voice.name)}
</span>
</div>
</li>
))}
</div>
)
);
})}
</ul>
</div>
</div>
@@ -1,9 +1,10 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useSync } from '@/hooks/useSync';
import { BookNote } from '@/types/book';
import { useBookDataStore } from '@/store/bookDataStore';
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
import { debounce } from '@/utils/debounce';
export const useNotesSync = (bookKey: string) => {
const { user } = useAuth();
@@ -13,10 +14,8 @@ export const useNotesSync = (bookKey: string) => {
const config = getConfig(bookKey);
const bookHash = bookKey.split('-')[0]!;
const lastSyncTime = useRef<number>(0);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const getNewNotes = () => {
const config = getConfig(bookKey);
if (!config?.location || !user) return [];
const bookNotes = config.booknotes ?? [];
const newNotes = bookNotes.filter(
@@ -28,35 +27,31 @@ export const useNotesSync = (bookKey: string) => {
return newNotes;
};
useEffect(() => {
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastSyncTime.current;
if (timeSinceLastSync > SYNC_NOTES_INTERVAL_SEC * 1000) {
lastSyncTime.current = now;
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleAutoSync = useCallback(
debounce(() => {
const newNotes = getNewNotes();
syncNotes(newNotes, bookHash, 'both');
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastSyncTime.current = Date.now();
const newNotes = getNewNotes();
syncNotes(newNotes, bookHash, 'both');
syncTimeoutRef.current = null;
},
SYNC_NOTES_INTERVAL_SEC * 1000 - timeSinceLastSync,
);
}
}, SYNC_NOTES_INTERVAL_SEC * 1000),
[lastSyncedAtNotes],
);
useEffect(() => {
if (!config?.location || !user) return;
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
useEffect(() => {
const processNewNote = (note: BookNote) => {
const config = getConfig(bookKey);
const oldNotes = config?.booknotes ?? [];
const existingNote = oldNotes.find((oldNote) => oldNote.id === note.id);
if (existingNote) {
if (existingNote.updatedAt < note.updatedAt) {
if (
existingNote.updatedAt < note.updatedAt ||
(existingNote.deletedAt ?? 0) < (note.deletedAt ?? 0)
) {
return { ...existingNote, ...note };
} else {
return { ...note, ...existingNote };
@@ -69,7 +69,7 @@ export const useProgressSync = (bookKey: string) => {
}, [bookKey]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedAutoSync = useCallback(
const handleAutoSync = useCallback(
debounce(() => {
syncConfig();
}, SYNC_PROGRESS_INTERVAL_SEC * 1000),
@@ -79,7 +79,7 @@ export const useProgressSync = (bookKey: string) => {
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (!progress?.location || !user) return;
debouncedAutoSync();
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
@@ -14,7 +14,7 @@ export const useTextSelector = (
) => {
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const { getView, getViewState, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const primaryLang = bookData.book?.primaryLanguage || 'en';
@@ -94,6 +94,9 @@ export const useTextSelector = (
const handlePointerup = (doc: Document, index: number) => {
// Available on iOS and Desktop, fired at touchend or mouseup
// Note that on Android, pointerup event is fired after an additional touch event
const viewState = getViewState(bookKey);
if (viewState?.ttsEnabled) return;
const sel = doc.getSelection() as Selection;
if (isValidSelection(sel)) {
if (osPlatform === 'ios') {
+11 -29
View File
@@ -1,18 +1,18 @@
'use client';
import clsx from 'clsx';
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { IoArrowBack } from 'react-icons/io5';
import { PiUserCircle } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { QuotaType, UserPlan } from '@/types/user';
import { getStoragePlanData, getUserPlan } from '@/utils/access';
import { UserPlan } from '@/types/user';
import { navigateToLibrary } from '@/utils/nav';
import { deleteUser } from '@/libs/user';
import { eventDispatcher } from '@/utils/event';
@@ -28,36 +28,13 @@ const ProfilePage = () => {
const { token, user, logout } = useAuth();
const { isTrafficLightVisible } = useTrafficLightStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [userPlan, setUserPlan] = useState<UserPlan>('free');
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const headerRef = useRef<HTMLDivElement>(null);
useTheme({ systemUIVisible: false });
useEffect(() => {
if (!user || !token) return;
try {
const userPlan = getUserPlan(token);
const storagePlan = getStoragePlanData(token);
const storageQuota = {
name: _('Cloud Storage'),
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
percentage: Math.round((storagePlan.usage / storagePlan.quota) * 100),
}),
used: Math.round(storagePlan.usage / 1024 / 1024),
total: Math.round(storagePlan.quota / 1024 / 1024),
unit: 'MB',
};
setUserPlan(userPlan);
setQuotas([storageQuota]);
} catch (error) {
console.error('Error loading user plan data:', error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const { quotas, userPlan } = useQuotaStats();
const handleGoBack = () => {
navigateToLibrary(router);
@@ -240,10 +217,15 @@ const ProfilePage = () => {
</div>
</div>
<div className='bg-base-300 mb-8 rounded-lg'>
<div className='mb-8 rounded-lg'>
<div className='p-0'>
{quotas && quotas.length > 0 ? (
<Quota quotas={quotas} showProgress className='h-10 pl-4 pr-2' />
<Quota
quotas={quotas}
showProgress
className='space-y-4'
labelClassName='pl-4 pr-2'
/>
) : (
<div className='h-10 animate-pulse'></div>
)}
+9 -6
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { ReactNode, useEffect, useState } from 'react';
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
@@ -45,6 +45,7 @@ const Dialog: React.FC<DialogProps> = ({
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(!snapHeight);
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const dialogRef = useRef<HTMLDialogElement>(null);
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640;
@@ -79,10 +80,10 @@ const Dialog: React.FC<DialogProps> = ({
}, [isOpen]);
const handleDragMove = (data: { clientY: number; deltaY: number }) => {
if (!isMobile) return;
if (!isMobile || !dialogRef.current) return;
const modal = document.querySelector('.modal-box') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
const modal = dialogRef.current.querySelector('.modal-box') as HTMLElement;
const overlay = dialogRef.current.querySelector('.overlay') as HTMLElement;
const heightFraction = data.clientY / window.innerHeight;
const newTop = Math.max(0.0, Math.min(1, heightFraction));
@@ -98,8 +99,9 @@ const Dialog: React.FC<DialogProps> = ({
};
const handleDragEnd = (data: { velocity: number; clientY: number }) => {
const modal = document.querySelector('.modal-box') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
if (!isMobile || !dialogRef.current) return;
const modal = dialogRef.current.querySelector('.modal-box') as HTMLElement;
const overlay = dialogRef.current.querySelector('.overlay') as HTMLElement;
if (!modal || !overlay) return;
const snapUpper = snapHeight ? 1 - snapHeight - SNAP_THRESHOLD : 0.5;
@@ -148,6 +150,7 @@ const Dialog: React.FC<DialogProps> = ({
return (
<dialog
ref={dialogRef}
id={id ?? 'dialog'}
open={isOpen}
className={clsx(
+9 -5
View File
@@ -10,12 +10,13 @@ type QuotaProps = {
unit: string;
}[];
className?: string;
labelClassName?: string;
showProgress?: boolean;
};
const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className, labelClassName }) => {
return (
<div className={clsx('text-base-content w-full space-y-2 rounded-md text-base sm:text-sm')}>
<div className={clsx('text-base-content w-full rounded-md text-base sm:text-sm', className)}>
{quotas.map((quota) => {
const usagePercentage = (quota.used / quota.total) * 100;
let bgColor = 'bg-green-500';
@@ -30,7 +31,7 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
key={quota.name}
className={clsx(
'relative w-full overflow-hidden rounded-md',
showProgress && 'border-base-300 border',
showProgress && 'bg-base-300',
)}
>
{showProgress && (
@@ -41,9 +42,12 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
)}
<div
className={clsx('relative flex items-center justify-between gap-4 p-2', className)}
className={clsx(
'relative flex items-center justify-between gap-4 p-2',
labelClassName,
)}
>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
<div className='lg:tooltip lg:tooltip-right' data-tip={quota.tooltip}>
<span className='truncate'>{quota.name}</span>
</div>
<div className='text-right text-xs'>
+10 -2
View File
@@ -10,19 +10,27 @@ type SelectProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: Option[];
disabled?: boolean;
className?: string;
};
export default function Select({ value, onChange, options, className }: SelectProps) {
export default function Select({
value,
onChange,
options,
className,
disabled = false,
}: SelectProps) {
return (
<select
value={value}
onChange={onChange}
className={clsx(
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
'bg-gray-600 text-white/75 focus:outline-none focus:ring-0',
'focus:outline-none focus:ring-0',
className,
)}
disabled={disabled}
style={{
textAlignLast: 'end',
}}
@@ -62,6 +62,7 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
const _ = useTranslation();
const [targetLang, setTargetLang] = useState('EN');
const { translate } = useTranslator({
provider: 'azure',
sourceLang: 'AUTO',
targetLang,
});
@@ -79,14 +80,7 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
const [downloaded, setDownloaded] = useState<number | null>(null);
useEffect(() => {
const locale = getLocale();
let userLang = locale.split('-')[0] || 'en';
if (locale === 'zh-CN') {
userLang = 'zh-Hans';
} else if (locale.startsWith('zh')) {
userLang = 'zh-Hant';
}
setTargetLang(userLang.toUpperCase());
setTargetLang(getLocale());
}, []);
useEffect(() => {
@@ -266,7 +260,7 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
}
};
if (!isMounted) {
if (!isMounted || !newVersion) {
return null;
}
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { QuotaType, UserPlan } from '@/types/user';
import { getStoragePlanData, getTranslationPlanData, getUserPlan } from '@/utils/access';
import { useTranslation } from './useTranslation';
export const useQuotaStats = () => {
const _ = useTranslation();
const { token, user } = useAuth();
const [quotas, setQuotas] = useState<QuotaType[]>([]);
const [userPlan, setUserPlan] = useState<UserPlan>('free');
useEffect(() => {
if (!user || !token) return;
const userPlan = getUserPlan(token);
const storagPlan = getStoragePlanData(token);
const storageQuota: QuotaType = {
name: _('Storage'),
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
}),
used: Math.round(storagPlan.usage / 1024 / 1024),
total: Math.round(storagPlan.quota / 1024 / 1024),
unit: 'MB',
};
const translationPlan = getTranslationPlanData(token);
const translationQuota: QuotaType = {
name: _('Translation Characters'),
tooltip: _('{{percentage}}% of Daily Translation Characters Used.', {
percentage: Math.round((translationPlan.usage / translationPlan.quota) * 100),
}),
used: Math.round(translationPlan.usage / 1024),
total: Math.round(translationPlan.quota / 1024),
unit: 'K',
};
setUserPlan(userPlan);
setQuotas([storageQuota, translationQuota]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return {
quotas,
userPlan,
};
};
+30 -10
View File
@@ -1,7 +1,9 @@
import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { getTranslator, getTranslators } from '@/services/translators';
import { ErrorCodes, getTranslator, getTranslators, TranslatorName } from '@/services/translators';
import { getFromCache, storeInCache, polish, UseTranslatorOptions } from '@/services/translators';
import { eventDispatcher } from '@/utils/event';
import { useTranslation } from './useTranslation';
export function useTranslator({
provider = 'deepl',
@@ -9,8 +11,10 @@ export function useTranslator({
targetLang = 'EN',
enablePolishing = true,
}: UseTranslatorOptions = {}) {
const _ = useTranslation();
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [selectedProvider, setSelectedProvider] = useState(provider);
const [translator, setTransltor] = useState(() => getTranslator(provider));
const [translators] = useState(() => getTranslators());
@@ -19,7 +23,15 @@ export function useTranslator({
}, [provider, sourceLang, targetLang]);
useEffect(() => {
setTransltor(getTranslator(provider));
const availableTranslators = getTranslators().filter(
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
);
const selectedTranslator =
availableTranslators.find((t) => t.name === provider) || availableTranslators[0]!;
const selectedProviderName = selectedTranslator.name as TranslatorName;
setTransltor(getTranslator(selectedProviderName));
setSelectedProvider(selectedProviderName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider]);
const translate = useCallback(
@@ -47,7 +59,7 @@ export function useTranslator({
text,
sourceLanguage,
targetLanguage,
provider,
selectedProvider,
);
if (cachedTranslation) return;
@@ -59,7 +71,7 @@ export function useTranslator({
if (textsNeedingTranslation.length === 0) {
const results = await Promise.all(
textsToTranslate.map((text) =>
getFromCache(text, sourceLanguage, targetLanguage, provider).then(
getFromCache(text, sourceLanguage, targetLanguage, selectedProvider).then(
(cached) => cached || text,
),
),
@@ -71,9 +83,9 @@ export function useTranslator({
setLoading(true);
try {
const translator = translators.find((t) => t.name === provider);
const translator = translators.find((t) => t.name === selectedProvider);
if (!translator) {
throw new Error(`No translator found for provider: ${provider}`);
throw new Error(`No translator found for provider: ${selectedProvider}`);
}
const translatedTexts = await translator.translate(
textsNeedingTranslation,
@@ -90,7 +102,7 @@ export function useTranslator({
translatedTexts[index] || '',
sourceLanguage,
targetLanguage,
provider,
selectedProvider,
);
}),
);
@@ -110,7 +122,7 @@ export function useTranslator({
originalText,
sourceLanguage,
targetLanguage,
provider,
selectedProvider,
);
if (cachedTranslation) {
@@ -123,13 +135,21 @@ export function useTranslator({
setLoading(false);
return enablePolishing ? polish(results, targetLanguage) : results;
} catch (err) {
console.error('Translation error:', err);
if (err instanceof Error && err.message.includes(ErrorCodes.DAILY_QUOTA_EXCEEDED)) {
eventDispatcher.dispatch('toast', {
message: _(
'Daily translation quota reached. Select another translate service to proceed.',
),
type: 'error',
});
setSelectedProvider('azure');
}
setLoading(false);
throw err instanceof Error ? err : new Error(String(err));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[provider, sourceLang, targetLang, translator, token],
[selectedProvider, sourceLang, targetLang, translator, token],
);
return {
+2 -2
View File
@@ -178,7 +178,7 @@ export class DocumentLoader {
);
}
public async open({ allowScript = false } = {}): Promise<{ book: BookDoc; format: BookFormat }> {
public async open(): Promise<{ book: BookDoc; format: BookFormat }> {
let book = null;
let format: BookFormat = 'EPUB';
if (!this.file.size) {
@@ -200,7 +200,7 @@ export class DocumentLoader {
format = 'FBZ';
} else {
const { EPUB } = await import('foliate-js/epub.js');
book = await new EPUB(loader).init({ allowScript });
book = await new EPUB(loader).init();
format = 'EPUB';
}
} else if (await this.isPDF()) {
+1 -13
View File
@@ -1,6 +1,7 @@
import { md5 } from 'js-md5';
import { randomMd5 } from '@/utils/misc';
import { LRUCache } from '@/utils/lru';
import { genSSML } from '@/utils/ssml';
const EDGE_SPEECH_URL =
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
@@ -173,19 +174,6 @@ export class EdgeSpeechTTS {
},
});
const genSSML = (lang: string, text: string, voice: string, rate: number) => {
const cleanedText = text.replace(/^<break\b[^>]*>/i, '');
return `
<speak version="1.0" xml:lang="${lang}">
<voice name="${voice}">
<prosody rate="${rate}">
${cleanedText}
</prosody>
</voice>
</speak>
`;
};
const genSendContent = (headerObj: Record<string, string>, content: string) => {
let header = '';
for (const key of Object.keys(headerObj)) {
@@ -1,19 +1,14 @@
import crypto from 'crypto';
import { supabase } from '@/utils/supabase';
import { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { supabase } from '@/utils/supabase';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { getDailyTranslationPlanData, getUserPlan } from '@/utils/access';
import { ErrorCodes } from '@/services/translators';
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
const ErrorCodes = {
UNAUTHORIZED: 'Unauthorized',
DEEPL_API_ERROR: 'DeepL API Error',
DAILY_QUOTA_EXCEEDED: 'Daily Quota Exceeded',
INTERNAL_SERVER_ERROR: 'Internal Server Error',
};
interface KVNamespace {
get(key: string): Promise<string | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
@@ -60,7 +55,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(405).json({ error: 'Method not allowed' });
}
const env = (req.env || {}) as CloudflareEnv;
const env = (getCloudflareContext().env || {}) as CloudflareEnv;
const hasKVCache = !!env['TRANSLATIONS_KV'];
const { user, token } = await getUserAndToken(req.headers['authorization']);
@@ -108,7 +103,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
}
// if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
return await callDeepLAPI(
user?.id,
@@ -181,14 +176,17 @@ async function callDeepLAPI(
throw new Error(`DeepL API error (${response.status}): ${errorText}`);
}
const data = await response.json();
const data = (await response.json()) as {
translations?: { text: string; detected_source_language?: string }[];
data?: string;
};
let translatedText = '';
let detectedSourceLanguage = '';
if (data.translations && data.translations.length > 0) {
translatedText = data.translations[0].text;
detectedSourceLanguage = data.translations[0].detected_source_language || '';
translatedText = data.translations[0]!.text;
detectedSourceLanguage = data.translations[0]!.detected_source_language || '';
} else if (data.data) {
translatedText = data.data;
}
+9 -6
View File
@@ -120,6 +120,9 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
theme: 'light',
overrideFont: false,
overrideLayout: false,
overrideColor: false,
codeHighlighting: false,
codeLanguage: 'auto-detect',
userStylesheet: '',
};
@@ -520,8 +523,8 @@ export const READEST_UPDATER_FILE = `${GITHUB_LATEST_DOWNLOAD}/latest.json`;
export const READEST_CHANGELOG_FILE = `${GITHUB_LATEST_DOWNLOAD}/release-notes.json`;
export const SYNC_PROGRESS_INTERVAL_SEC = 3;
export const SYNC_NOTES_INTERVAL_SEC = 10;
export const SYNC_BOOKS_INTERVAL_SEC = 10;
export const SYNC_NOTES_INTERVAL_SEC = 5;
export const SYNC_BOOKS_INTERVAL_SEC = 5;
export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60;
export const MAX_ZOOM_LEVEL = 500;
@@ -535,9 +538,9 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
};
export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = {
free: 100 * 1024,
plus: 1 * 1024 * 1024,
pro: 10 * 1024 * 1024,
free: 50 * 1024,
plus: 500 * 1024,
pro: 1024 * 1024,
};
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
@@ -623,7 +626,7 @@ export const TRANSLATED_LANGS = {
pl: 'Polski',
tr: 'Türkçe',
hi: 'हिन्दी',
id: 'Bahasa Indonesia',
id: 'Indonesia',
vi: 'Tiếng Việt',
'zh-CN': '简体中文',
'zh-TW': '正體中文',
@@ -1,12 +1,18 @@
import { getAPIBaseUrl } from '@/services/environment';
import { stubTranslation as _ } from '@/utils/misc';
import { TranslationProvider } from '../types';
import { ErrorCodes, TranslationProvider } from '../types';
import { UserPlan } from '@/types/user';
import { getUserPlan } from '@/utils/access';
import { DEFAULT_DAILY_TRANSLATION_QUOTA } from '@/services/constants';
import { saveDailyUsage } from '../utils';
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
export const deeplProvider: TranslationProvider = {
name: 'deepl',
label: _('DeepL'),
authRequired: true,
quotaExceeded: false,
translate: async (
text: string[],
sourceLang: string,
@@ -14,14 +20,22 @@ export const deeplProvider: TranslationProvider = {
token?: string | null,
useCache: boolean = false,
): Promise<string[]> => {
const authRequired = deeplProvider.authRequired;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
let userPlan: UserPlan = 'free';
if (token) {
userPlan = getUserPlan(token);
headers['Authorization'] = `Bearer ${token}`;
}
if (authRequired && !token) {
throw new Error('Authentication token is required for DeepL translation');
}
const body = JSON.stringify({
text: text,
source_lang: sourceLang.toUpperCase(),
@@ -29,22 +43,38 @@ export const deeplProvider: TranslationProvider = {
use_cache: useCache,
});
const response = await fetch(DEEPL_API_ENDPOINT, { method: 'POST', headers, body });
const quota = DEFAULT_DAILY_TRANSLATION_QUOTA[userPlan];
try {
const response = await fetch(DEEPL_API_ENDPOINT, { method: 'POST', headers, body });
if (!response.ok) {
throw new Error(`Translation failed with status ${response.status}`);
}
const data = await response.json();
if (!data || !data.translations) {
throw new Error('Invalid response from translation service');
}
return text.map((line, i) => {
if (!line?.trim().length) {
return line;
if (!response.ok) {
const data = await response.json();
if (data && data.error && data.error === ErrorCodes.DAILY_QUOTA_EXCEEDED) {
saveDailyUsage(quota);
deeplProvider.quotaExceeded = true;
throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED);
}
throw new Error(`Translation failed with status ${response.status}`);
}
return data.translations?.[i]?.text || line;
});
const data = await response.json();
if (!data || !data.translations) {
throw new Error('Invalid response from translation service');
}
return text.map((line, i) => {
if (!line?.trim().length) {
return line;
}
const translation = data.translations?.[i];
if (translation?.daily_usage) {
saveDailyUsage(translation.daily_usage);
deeplProvider.quotaExceeded = data.daily_usage >= quota;
}
return translation?.text || line;
});
} catch (error) {
throw error;
}
},
};
@@ -12,7 +12,7 @@ function createTranslator<T extends string>(
`Translator name "${name}" does not match implementation name "${implementation.name}"`,
);
}
return { ...implementation, name };
return implementation as TranslationProvider & { name: T };
}
const deeplTranslator = createTranslator('deepl', deeplProvider);

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