Compare commits

...

30 Commits

Author SHA1 Message Date
Huang Xin e1691661d9 release: version 0.9.82 (#2155) 2025-10-01 16:45:01 +02:00
Huang Xin bc8e419d51 compat(koplugin): make meta_hash extraction more robust for calibre conversions and metadata edits, fixes #1838 (#2154) 2025-10-01 16:38:29 +02:00
make_aguess ccad1ca0d9 Add support for mono theme (#2122) (#2153) 2025-10-01 15:30:09 +02:00
Huang Xin 75c315fca8 fix(koplugin): only prompt network connection during interactive push progress, closes #2137 (#2152) 2025-10-01 14:57:58 +02:00
Huang Xin bdef593cdd feat: support global settings from library menu, closes #2140 (#2151) 2025-10-01 09:59:37 +02:00
Huang Xin d8943fc0c5 compat: normalize OKLCH color syntax for older iOS Safari, closes #990 (#2150) 2025-10-01 07:59:45 +02:00
Huang Xin cdd274e5ca compat: add support for WebView down to version 92, closes #2139 (#2145) 2025-09-30 19:14:35 +02:00
Huang Xin 2230741779 fix(i18n): apply system language on app start, closes #2141 (#2144) 2025-09-30 10:11:18 +02:00
Huang Xin 0e6950a60f i18n: localization for notification title and text (#2143) 2025-09-30 10:03:33 +02:00
Huang Xin 1d4541e353 feat: request manage external storage permission when changing data directory to sdcard root on Android (#2142) 2025-09-30 08:59:16 +02:00
Huang Xin 0a1e0212e2 feat: supported background TTS with media session controls, closes #2099 and closes #964 (#2138) 2025-09-29 19:45:17 +02:00
Huang Xin 25b44176b9 fix(tts): parse default language in ssml without translated text (#2135) 2025-09-27 17:52:54 +02:00
Huang Xin e1deff341d feat: supported changing data location on Android (#2131) 2025-09-26 17:32:16 +02:00
Huang Xin 3fc4c05e50 feat: build portable Windows binaries with app data kept within the executable directory (#2126) 2025-09-26 11:23:55 +02:00
Huang Xin 1843a74dbb feat: supported changing data location on desktop platforms, closes #2047 and closes #478 (#2125) 2025-09-26 10:22:41 +02:00
Huang Xin 9aa6a8e6b9 chore: bump tauri to the latest version (#2117) 2025-09-25 09:32:55 +02:00
Huang Xin 1de89069ce fix: handle pointer events for window dragging on touch screen Windows, closes #2010 (#2116)
* fix: handle pointer events for window dragging on touch screen Windows, closes #2010

* chore: fix rust-lint
2025-09-25 06:41:08 +02:00
Huang Xin 1e6525d085 fix(sync): handle incomplete config data from koreader (#2115) 2025-09-25 11:49:43 +08:00
Huang Xin d9d71b7d38 fix(css): overriding hard-coded font weight (#2113) 2025-09-24 17:03:17 +02:00
Huang Xin e85dec2b49 fix(sync): force a full sync after a certain period, closes #2111 (#2112) 2025-09-24 16:52:17 +02:00
Huang Xin c0f77b3368 fix(tts): abortable prefetch of tts data, closes #2037 (#2110) 2025-09-24 12:19:07 +02:00
Huang Xin 8c9855d167 fix(layout): avoid changing the safe area insets when virtual keyboard pops up on Android, closes #2105 (#2108) 2025-09-24 07:28:59 +02:00
Huang Xin 7c12bc8595 fix(mobi): fixed footnotes parsing for some MOBI/AZW files (#2106) 2025-09-23 08:53:43 +02:00
Huang Xin 5102f6b0ff fix(tts): handle und language code, closes #2100 (#2102) 2025-09-22 18:15:28 +02:00
Huang Xin dd40b9bae0 fix(layout): adjust font size instead of zooming HTML, closes #2088 (#2101)
Prevents issues with zoom handling in WebKit-based browsers such as Safari and WebKitGTK.
2025-09-22 17:43:22 +02:00
Huang Xin 98b4026990 fix(layout): scrollable book view and about window, closes #2090 (#2098) 2025-09-22 13:44:54 +02:00
Huang Xin b0640ba55b fix(fonts): higher priority for CJK fonts so that it can override system CJK glyphs (#2093) 2025-09-21 08:22:49 +02:00
Huang Xin c9a1557674 refactor: support custom root dir for readest file system (#2092) 2025-09-21 07:59:27 +02:00
Huang Xin 855f98722c tts: add more languages for edge tts (#2089) 2025-09-20 01:58:11 +02:00
Huang Xin 21a82b70c7 fix(a11y): better contrast for disabled buttons, closes #2083 (#2084) 2025-09-19 13:40:45 +02:00
155 changed files with 4795 additions and 1421 deletions
+1
View File
@@ -19,6 +19,7 @@ jobs:
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Install system dependencies
run: |
sudo apt-get update
+9 -3
View File
@@ -153,7 +153,7 @@ jobs:
- name: install NDK (for Android build only)
if: matrix.config.release == 'android'
run: sdkmanager "ndk;27.0.11902837"
run: sdkmanager "ndk;28.2.13676358"
- name: install dependencies
run: pnpm install
@@ -200,7 +200,7 @@ jobs:
if: matrix.config.release == 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.11902837
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
@@ -288,12 +288,18 @@ jobs:
echo "Uploading release notes to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} apps/readest-app/release-notes.json --clobber
- name: upload portable binaries (Windows only)
- name: build and upload portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
echo "Building Portable Binaries"
pushd apps/readest-app/
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
pnpm tauri build
popd
echo "Uploading Portable Binaries"
arch=${{ matrix.config.arch }}
version=${{ needs.get-release.outputs.release_version }}
Generated
+572 -554
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -4,9 +4,6 @@ members = [
"packages/tauri/crates/tauri",
"packages/tauri/crates/tauri-utils",
"packages/tauri/crates/tauri-build",
"packages/tauri-plugins/plugins/fs",
"packages/tauri-plugins/plugins/dialog",
"packages/tauri-plugins/plugins/deep-link",
]
resolver = "2"
@@ -38,7 +35,3 @@ rust-version = "1.77.2"
tauri = { path = "packages/tauri/crates/tauri" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-os = { path = "packages/tauri-plugins/plugins/os" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
tauri-plugin-dialog = { path = "packages/tauri-plugins/plugins/dialog" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
+11 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.81",
"version": "0.9.82",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -55,16 +55,16 @@
"@supabase/supabase-js": "^2.55.0",
"@tauri-apps/api": "2.8.0",
"@tauri-apps/plugin-cli": "^2.4.0",
"@tauri-apps/plugin-deep-link": "^2.4.1",
"@tauri-apps/plugin-dialog": "^2.3.2",
"@tauri-apps/plugin-fs": "^2.4.1",
"@tauri-apps/plugin-deep-link": "^2.4.3",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-haptics": "^2.3.0",
"@tauri-apps/plugin-http": "^2.5.1",
"@tauri-apps/plugin-log": "^2.6.0",
"@tauri-apps/plugin-opener": "^2.4.0",
"@tauri-apps/plugin-os": "^2.3.0",
"@tauri-apps/plugin-http": "^2.5.2",
"@tauri-apps/plugin-log": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-os": "^2.3.1",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "~2.3.0",
"@tauri-apps/plugin-shell": "~2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"@zip.js/zip.js": "^2.7.53",
"app-store-server-api": "^0.17.1",
@@ -105,7 +105,7 @@
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.8.1",
"@tauri-apps/cli": "2.8.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/cors": "^2.8.17",
@@ -120,6 +120,7 @@
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"caniuse-lite": "^1.0.30001746",
"cpx2": "^8.0.0",
"daisyui": "^4.12.24",
"dotenv-cli": "^7.4.4",
@@ -579,5 +579,41 @@
"Toggle Translation": "تبديل الترجمة",
"Translation Disabled": "تم تعطيل الترجمة",
"Minimize": "تصغير",
"Maximize or Restore": "تكبير أو استعادة"
"Maximize or Restore": "تكبير أو استعادة",
"Exit Parallel Read": "الخروج من القراءة المتزامنة",
"Enter Parallel Read": "الدخول في القراءة المتزامنة",
"Zoom Level": "مستوى التكبير",
"Zoom Out": "تصغير",
"Reset Zoom": "إعادة تعيين التكبير",
"Zoom In": "تكبير",
"Zoom Mode": "وضع التكبير",
"Single Page": "صفحة واحدة",
"Auto Spread": "انتشار تلقائي",
"Fit Page": "تناسب الصفحة",
"Fit Width": "تناسب العرض",
"Failed to select directory": "فشل في اختيار الدليل",
"The new data directory must be different from the current one.": "يجب أن يكون دليل البيانات الجديد مختلفًا عن الدليل الحالي.",
"Migration failed: {{error}}": "فشل في الترحيل: {{error}}",
"Change Data Location": "تغيير موقع البيانات",
"Current Data Location": "موقع البيانات الحالي",
"Total size: {{size}}": "الحجم الكلي: {{size}}",
"Calculating file info...": "جارٍ حساب معلومات الملف...",
"New Data Location": "موقع البيانات الجديد",
"Choose Different Folder": "اختر مجلدًا مختلفًا",
"Choose New Folder": "اختر مجلدًا جديدًا",
"Migrating data...": "جارٍ ترحيل البيانات...",
"Copying: {{file}}": "جارٍ نسخ: {{file}}",
"{{current}} of {{total}} files": "{{current}} من {{total}} ملف",
"Migration completed successfully!": "اكتمل الترحيل بنجاح!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "تم نقل بياناتك إلى الموقع الجديد. يرجى إعادة تشغيل التطبيق لإكمال العملية.",
"Migration failed": "فشل في الترحيل",
"Important Notice": "إشعار مهم",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "سيؤدي ذلك إلى نقل جميع بيانات التطبيق الخاصة بك إلى الموقع الجديد. تأكد من أن الوجهة تحتوي على مساحة خالية كافية.",
"Restart App": "إعادة تشغيل التطبيق",
"Start Migration": "بدء الترحيل",
"Advanced Settings": "إعدادات متقدمة",
"File count: {{size}}": "عدد الملفات: {{size}}",
"Background Read Aloud": "قراءة بصوت عالٍ في الخلفية",
"Ready to read aloud": "جاهز للقراءة بصوت عالٍ",
"Read Aloud": "القراءة بصوت عالٍ"
}
@@ -563,5 +563,41 @@
"Toggle Translation": "অনুবাদ টগল করুন",
"Translation Disabled": "অনুবাদ অক্ষম",
"Minimize": "সঙ্কুচিত করুন",
"Maximize or Restore": "বৃহৎ করুন বা পুনরুদ্ধার করুন"
"Maximize or Restore": "বৃহৎ করুন বা পুনরুদ্ধার করুন",
"Exit Parallel Read": "সমান্তরাল পড়া থেকে বেরিয়ে আসুন",
"Enter Parallel Read": "সমান্তরাল পড়ায় প্রবেশ করুন",
"Zoom Level": "জুম লেভেল",
"Zoom Out": "জুম আউট",
"Reset Zoom": "জুম রিসেট",
"Zoom In": "জুম ইন",
"Zoom Mode": "জুম মোড",
"Single Page": "একক পৃষ্ঠা",
"Auto Spread": "অটো স্প্রেড",
"Fit Page": "ফিট পৃষ্ঠা",
"Fit Width": "ফিট প্রস্থ",
"Failed to select directory": "ডিরেক্টরি নির্বাচন করতে ব্যর্থ",
"The new data directory must be different from the current one.": "নতুন ডেটা ডিরেক্টরি বর্তমানটির থেকে আলাদা হতে হবে।",
"Migration failed: {{error}}": "মাইগ্রেশন ব্যর্থ হয়েছে: {{error}}",
"Change Data Location": "ডেটা অবস্থান পরিবর্তন করুন",
"Current Data Location": "বর্তমান ডেটা অবস্থান",
"Total size: {{size}}": "মোট আকার: {{size}}",
"Calculating file info...": "ফাইলের তথ্য গণনা করা হচ্ছে...",
"New Data Location": "নতুন ডেটা অবস্থান",
"Choose Different Folder": "ভিন্ন ফোল্ডার নির্বাচন করুন",
"Choose New Folder": "নতুন ফোল্ডার নির্বাচন করুন",
"Migrating data...": "ডেটা স্থানান্তরিত হচ্ছে...",
"Copying: {{file}}": "কপি করা হচ্ছে: {{file}}",
"{{current}} of {{total}} files": "{{current}} এর {{total}} ফাইল",
"Migration completed successfully!": "মাইগ্রেশন সফলভাবে সম্পন্ন হয়েছে!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "আপনার ডেটা নতুন অবস্থানে স্থানান্তরিত হয়েছে। প্রক্রিয়া সম্পন্ন করতে দয়া করে অ্যাপ্লিকেশনটি পুনরায় চালু করুন।",
"Migration failed": "মাইগ্রেশন ব্যর্থ হয়েছে",
"Important Notice": "গুরুতর বিজ্ঞপ্তি",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "এটি আপনার সমস্ত অ্যাপ ডেটা নতুন অবস্থানে স্থানান্তরিত করবে। নিশ্চিত করুন যে গন্তব্যে পর্যাপ্ত ফ্রি স্পেস রয়েছে।",
"Restart App": "অ্যাপ পুনরায় চালু করুন",
"Start Migration": "মাইগ্রেশন শুরু করুন",
"Advanced Settings": "উন্নত সেটিংস",
"File count: {{size}}": "ফাইলের সংখ্যা: {{size}}",
"Background Read Aloud": "পটভূমিতে উচ্চস্বরে পড়ুন",
"Ready to read aloud": "উচ্চস্বরে পড়ার জন্য প্রস্তুত",
"Read Aloud": "উচ্চস্বরে পড়ুন"
}
@@ -539,25 +539,61 @@
"OFF": "བརྗེ་བ",
"Reading Progress": "ཀློག་པའི་ཡར་འཕེལ།",
"Page Margin": "ཤོག་གདོང་།",
"Remove Bookmark": "བརྗེ་བའི་སྒྲིག་འདེམས་པ།",
"Add Bookmark": "བརྗེ་བའི་སྒྲིག་འདེམས་པ།",
"Books Content": "དེབ་ཀྱི་དོན་ཚན",
"Jump to Location": "གནས་སྟངས་འདེམས་པ།",
"Unpin Notebook": "དེབ་སྣོད་འདེམས་པ།",
"Pin Notebook": "དེབ་སྣོད་འདེམས་པ།",
"Hide Search Bar": "འཚོལ་བའི་སྒྲིག་འདེམས་པ།",
"Show Search Bar": "འཚོལ་བའི་སྒྲིག་འདེམས་པ།",
"On {{current}} of {{total}} page": "ཤོག་ {{current}} དང་ {{total}}",
"Section Title": "དོན་ཚན་གྱི་མགོ་སྒམ།",
"Decrease": "བཏང་བ།",
"Increase": "བཀྲ་ཤིས་བཏང་བ།",
"Settings Panels": "སྒྲིག་སྟངས་མ་ལག",
"Settings": "སྒྲིག་སྟངས",
"Unpin Sidebar": "གཡས་སྒོ་འདེམས་པ།",
"Pin Sidebar": "གཡས་སྒོ་འདེམས་པ།",
"Toggle Sidebar": "གཡས་སྒོ་འདེམས་པ།",
"Toggle Translation": "བརྗེ་བའི་སྒྲིག་འདེམས་པ།",
"Translation Disabled": "བརྗེ་བའི་སྒྲིག་འདེམས་པ།",
"Minimize": "སྐར་མ་འཇོག",
"Maximize or Restore": "བཀྲ་ཤིས་བཏང་བ།"
"Remove Bookmark": "དེབ་གྲངས་འདོར་བ།",
"Add Bookmark": "དེབ་གྲངས་སྣོན་པ།",
"Books Content": "དེབ་ཀྱི་ནང་དོན།",
"Jump to Location": "གནས་ས་ལ་འགྲོ།",
"Unpin Notebook": "དེབ་སྣོད་བཤོལ་བ།",
"Pin Notebook": "དེབ་སྣོད་བཀར་བ།",
"Hide Search Bar": "འཚོལ་བཤེར་སྒོ་སྦེད།",
"Show Search Bar": "འཚོལ་བཤེར་སྒོ་སྟོན།",
"On {{current}} of {{total}} page": "ཤོག་ངོས་ {{total}} ནས་ {{current}} ལ།",
"Section Title": "དོན་ཚན་མགོ་མིང་།",
"Decrease": "མར་འབེབས།",
"Increase": "ཡར་འཕར།",
"Settings Panels": "སྒྲིག་སྟངས་གནས་སྟོན།",
"Settings": "སྒྲིག་སྟངས",
"Unpin Sidebar": "ཟུར་སྒོ་བཤོལ་བ།",
"Pin Sidebar": "ཟུར་སྒོ་བཀར་བ།",
"Toggle Sidebar": "ཟུར་སྒོ་སྤོ་བ།",
"Toggle Translation": "ཡིག་སྒྱུར་སྤོ་བ།",
"Translation Disabled": "ཡིག་སྒྱུར་མེད།",
"Minimize": "ཆུང་དུ་བསྡུ།",
"Maximize or Restore": "ཆེན་པོ་བཟོ/གསོག།",
"Exit Parallel Read": "མཉམ་དུ་ཀློག་ཕྱིར་འཐེན།",
"Enter Parallel Read": "མཉམ་དུ་ཀློག་འཛུལ།",
"Zoom Level": "རྒྱབ་སྐོར་གྱི་གདམ་ཁ",
"Zoom Out": "རྒྱབ་སྐོར་བཏང་བ།",
"Reset Zoom": "རྒྱབ་སྐོར་རྩོལ་བ།",
"Zoom In": "རྒྱབ་སྐོར་འཕེལ་བ།",
"Zoom Mode": "རྒྱབ་སྐོར་སྤོ་བ།",
"Single Page": "དེབ་གཅིག",
"Auto Spread": "རང་འགུལ་སྤྲོད།",
"Fit Page": "དེབ་འདེམས་པ།",
"Fit Width": "ཁྱབ་སྒྲིག་འདེམས་པ།",
"Failed to select directory": "གནས་ས་འདེམས་པ་བྱས་མ་ཐུབ།",
"The new data directory must be different from the current one.": "གསར་བཅས་སྐོར་འདེམས་པ་གནས་ས་དང་མཉམ་འབྱུང་བར་བྱས་མ་ཐུབ།",
"Migration failed: {{error}}": "མཉམ་དུ་ཀློག་འཛུལ་བྱས་མ་ཐུབ།",
"Change Data Location": "གསར་བཅས་སྐོར་འདེམས་པ།",
"Current Data Location": "ད་ལྟ་བཅས་སྐོར་འདེམས་པ།",
"Total size: {{size}}": "དངོས་གནས་བཅས་: {{size}}",
"Calculating file info...": "དེབ་གནས་བཅས་པའི་གདམ་ཁ་བཟོ་བྱས་མ་ཐུབ།",
"New Data Location": "གསར་བཅས་སྐོར་འདེམས་པ།",
"Choose Different Folder": "གཞན་དེབ་སྣོད་བཀར་བ།",
"Choose New Folder": "གསར་བཅས་སྣོད་བཀར་བ།",
"Migrating data...": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Copying: {{file}}": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"{{current}} of {{total}} files": "{{current}} དང་ {{total}} དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Migration completed successfully!": "མཉམ་དུ་ཀློག་འཛུལ་བྱས་མ་ཐུབ།",
"Your data has been moved to the new location. Please restart the application to complete the process.": "ཁྱོད་ཀྱི་དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Migration failed": "མཉམ་དུ་ཀློག་འཛུལ་བྱས་མ་ཐུབ།",
"Important Notice": "དོན་གཞི་བརྗེ་བ།",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "འདི་ནས་ཁྱོད་ཀྱི་ཨང་གཏོད་དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Restart App": "དེབ་ཀྱི་སྤྱོད་བྱས་མ་ཐུབ།",
"Start Migration": "མཉམ་དུ་ཀློག་འཛུལ་བྱས་མ་ཐུབ།",
"Advanced Settings": "དབང་བསྐྱོད་སྒྲིག་སྟངས།",
"File count: {{size}}": "དེབ་གནས་བཅས་པའི་ཨང་། {{size}}",
"Background Read Aloud": "གནས་སྟངས་ཀྱིས་ཀློག་བྱེད།",
"Ready to read aloud": "དེབ་ཀྱི་སྤྱོད་བྱས་མ་ཐུབ།",
"Read Aloud": "ཀློག་བྱེད།"
}
@@ -563,5 +563,41 @@
"Toggle Translation": "Übersetzung umschalten",
"Translation Disabled": "Übersetzung deaktiviert",
"Minimize": "Minimieren",
"Maximize or Restore": "Maximieren oder Wiederherstellen"
"Maximize or Restore": "Maximieren oder Wiederherstellen",
"Exit Parallel Read": "Parallel Read beenden",
"Enter Parallel Read": "Parallel Read starten",
"Zoom Level": "Zoomstufe",
"Zoom Out": "Hineinzoomen",
"Reset Zoom": "Zoom zurücksetzen",
"Zoom In": "Herauszoomen",
"Zoom Mode": "Zoom-Modus",
"Single Page": "Einzelne Seite",
"Auto Spread": "Automatische Verbreitung",
"Fit Page": "Seite anpassen",
"Fit Width": "Breite anpassen",
"Failed to select directory": "Fehler beim Auswählen des Verzeichnisses",
"The new data directory must be different from the current one.": "Das neue Datenverzeichnis muss sich vom aktuellen unterscheiden.",
"Migration failed: {{error}}": "Migration fehlgeschlagen: {{error}}",
"Change Data Location": "Datenstandort ändern",
"Current Data Location": "Aktueller Datenstandort",
"Total size: {{size}}": "Gesamtgröße: {{size}}",
"Calculating file info...": "Dateiinformationen werden berechnet...",
"New Data Location": "Neuer Datenstandort",
"Choose Different Folder": "Anderen Ordner wählen",
"Choose New Folder": "Neuen Ordner wählen",
"Migrating data...": "Daten werden migriert...",
"Copying: {{file}}": "Kopiere: {{file}}",
"{{current}} of {{total}} files": "{{current}} von {{total}} Dateien",
"Migration completed successfully!": "Migration erfolgreich abgeschlossen!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Ihre Daten wurden an den neuen Speicherort verschoben. Bitte starten Sie die Anwendung neu, um den Vorgang abzuschließen.",
"Migration failed": "Migration fehlgeschlagen",
"Important Notice": "Wichtiger Hinweis",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Dies wird alle Ihre App-Daten an den neuen Speicherort verschieben. Stellen Sie sicher, dass das Ziel über ausreichend freien Speicherplatz verfügt.",
"Restart App": "App neu starten",
"Start Migration": "Migration starten",
"Advanced Settings": "Erweiterte Einstellungen",
"File count: {{size}}": "Dateianzahl: {{size}}",
"Background Read Aloud": "Hintergrund-Vorlesen",
"Ready to read aloud": "Bereit zum Vorlesen",
"Read Aloud": "Vorlesen"
}
@@ -563,5 +563,41 @@
"Toggle Translation": "Εναλλαγή μετάφρασης",
"Translation Disabled": "Η μετάφραση είναι απενεργοποιημένη",
"Minimize": "Ελαχιστοποίηση",
"Maximize or Restore": "Μεγιστοποίηση ή Επαναφορά"
"Maximize or Restore": "Μεγιστοποίηση ή Επαναφορά",
"Exit Parallel Read": "Έξοδος από την παράλληλη ανάγνωση",
"Enter Parallel Read": "Είσοδος στην παράλληλη ανάγνωση",
"Zoom Level": "Επίπεδο ζουμ",
"Zoom Out": "Μείωση ζουμ",
"Reset Zoom": "Επαναφορά ζουμ",
"Zoom In": "Αύξηση ζουμ",
"Zoom Mode": "Λειτουργία ζουμ",
"Single Page": "Μοναδική σελίδα",
"Auto Spread": "Αυτόματη εξάπλωση",
"Fit Page": "Προσαρμογή σελίδας",
"Fit Width": "Προσαρμογή πλάτους",
"Failed to select directory": "Αποτυχία επιλογής καταλόγου",
"The new data directory must be different from the current one.": "Ο νέος κατάλογος δεδομένων πρέπει να είναι διαφορετικός από τον τρέχοντα.",
"Migration failed: {{error}}": "Η μετανάστευση απέτυχε: {{error}}",
"Change Data Location": "Αλλαγή τοποθεσίας δεδομένων",
"Current Data Location": "Τρέχουσα τοποθεσία δεδομένων",
"Total size: {{size}}": "Συνολικό μέγεθος: {{size}}",
"Calculating file info...": "Υπολογισμός πληροφοριών αρχείου...",
"New Data Location": "Νέα τοποθεσία δεδομένων",
"Choose Different Folder": "Επιλέξτε διαφορετικό φάκελο",
"Choose New Folder": "Επιλέξτε νέο φάκελο",
"Migrating data...": "Μεταφορά δεδομένων...",
"Copying: {{file}}": "Αντιγραφή: {{file}}",
"{{current}} of {{total}} files": "{{current}} από {{total}} αρχεία",
"Migration completed successfully!": "Η μετανάστευση ολοκληρώθηκε με επιτυχία!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Τα δεδομένα σας έχουν μεταφερθεί στη νέα τοποθεσία. Παρακαλώ επανεκκινήστε την εφαρμογή για να ολοκληρώσετε τη διαδικασία.",
"Migration failed": "Η μετανάστευση απέτυχε",
"Important Notice": "Σημαντική ειδοποίηση",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Αυτό θα μεταφέρει όλα τα δεδομένα της εφαρμογής σας στη νέα τοποθεσία. Βεβαιωθείτε ότι ο προορισμός έχει αρκετό ελεύθερο χώρο.",
"Restart App": "Επανεκκίνηση εφαρμογής",
"Start Migration": "Έναρξη μετανάστευσης",
"Advanced Settings": "Προηγμένες ρυθμίσεις",
"File count: {{size}}": "Αριθμός αρχείων: {{size}}",
"Background Read Aloud": "Ανάγνωση στο παρασκήνιο",
"Ready to read aloud": "Έτοιμο για ανάγνωση",
"Read Aloud": "Ανάγνωση"
}
@@ -567,5 +567,41 @@
"Toggle Translation": "Alternar Traducción",
"Translation Disabled": "Traducción Deshabilitada",
"Minimize": "Minimizar",
"Maximize or Restore": "Maximizar o Restaurar"
"Maximize or Restore": "Maximizar o Restaurar",
"Exit Parallel Read": "Salir de Lectura Paralela",
"Enter Parallel Read": "Entrar en Lectura Paralela",
"Zoom Level": "Nivel de Zoom",
"Zoom Out": "Alejar",
"Reset Zoom": "Restablecer Zoom",
"Zoom In": "Acercar",
"Zoom Mode": "Modo de Zoom",
"Single Page": "Página Única",
"Auto Spread": "Distribución Automática",
"Fit Page": "Ajustar Página",
"Fit Width": "Ajustar Ancho",
"Failed to select directory": "Error al seleccionar el directorio",
"The new data directory must be different from the current one.": "El nuevo directorio de datos debe ser diferente del actual.",
"Migration failed: {{error}}": "La migración falló: {{error}}",
"Change Data Location": "Cambiar Ubicación de Datos",
"Current Data Location": "Ubicación Actual de Datos",
"Total size: {{size}}": "Tamaño total: {{size}}",
"Calculating file info...": "Calculando información del archivo...",
"New Data Location": "Nueva Ubicación de Datos",
"Choose Different Folder": "Elegir Carpeta Diferente",
"Choose New Folder": "Elegir Nueva Carpeta",
"Migrating data...": "Migrando datos...",
"Copying: {{file}}": "Copiando: {{file}}",
"{{current}} of {{total}} files": "{{current}} de {{total}} archivos",
"Migration completed successfully!": "¡Migración completada con éxito!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Tus datos han sido movidos a la nueva ubicación. Por favor, reinicia la aplicación para completar el proceso.",
"Migration failed": "La migración falló",
"Important Notice": "Aviso Importante",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Esto moverá todos los datos de tu aplicación a la nueva ubicación. Asegúrate de que el destino tenga suficiente espacio libre.",
"Restart App": "Reiniciar Aplicación",
"Start Migration": "Iniciar Migración",
"Advanced Settings": "Configuraciones Avanzadas",
"File count: {{size}}": "Número de archivos: {{size}}",
"Background Read Aloud": "Lectura en segundo plano",
"Ready to read aloud": "Listo para leer en voz alta",
"Read Aloud": "Leer en voz alta"
}
@@ -567,5 +567,41 @@
"Toggle Translation": "Alternar Traduction",
"Translation Disabled": "Traduction Désactivée",
"Minimize": "Minimiser",
"Maximize or Restore": "Maximiser ou Restaurer"
"Maximize or Restore": "Maximiser ou Restaurer",
"Exit Parallel Read": "Quitter la Lecture Parallèle",
"Enter Parallel Read": "Entrer dans la Lecture Parallèle",
"Zoom Level": "Zoom",
"Zoom Out": "Réduire le Zoom",
"Reset Zoom": "Réinitialiser le Zoom",
"Zoom In": "Agrandir le Zoom",
"Zoom Mode": "Mode Zoom",
"Single Page": "Page Unique",
"Auto Spread": "Répartition Automatique",
"Fit Page": "Ajuster la Page",
"Fit Width": "Ajuster la Largeur",
"Failed to select directory": "Échec de la sélection du répertoire",
"The new data directory must be different from the current one.": "Le nouveau répertoire de données doit être différent de l'actuel.",
"Migration failed: {{error}}": "La migration a échoué : {{error}}",
"Change Data Location": "Changer l'Emplacement des Données",
"Current Data Location": "Emplacement Actuel des Données",
"Total size: {{size}}": "Taille totale : {{size}}",
"Calculating file info...": "Calcul des informations sur le fichier...",
"New Data Location": "Nouvel Emplacement des Données",
"Choose Different Folder": "Choisir un Dossier Différent",
"Choose New Folder": "Choisir un Nouveau Dossier",
"Migrating data...": "Migration des données...",
"Copying: {{file}}": "Copie : {{file}}",
"{{current}} of {{total}} files": "{{current}} sur {{total}} fichiers",
"Migration completed successfully!": "Migration terminée avec succès !",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Vos données ont été déplacées vers le nouvel emplacement. Veuillez redémarrer l'application pour terminer le processus.",
"Migration failed": "La migration a échoué",
"Important Notice": "Avis Important",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Cela déplacera toutes vos données d'application vers le nouvel emplacement. Assurez-vous que la destination dispose de suffisamment d'espace libre.",
"Restart App": "Redémarrer l'Application",
"Start Migration": "Démarrer la Migration",
"Advanced Settings": "Paramètres Avancés",
"File count: {{size}}": "Nombre de fichiers : {{size}}",
"Background Read Aloud": "Lecture Vocale en Arrière-Plan",
"Ready to read aloud": "Prêt à lire à haute voix",
"Read Aloud": "Lire à haute voix"
}
@@ -563,5 +563,41 @@
"Toggle Translation": "अनुवाद टॉगल करें",
"Translation Disabled": "अनुवाद अक्षम",
"Minimize": "न्यूनतम करें",
"Maximize or Restore": "अधिकतम या पुनर्स्थापित करें"
"Maximize or Restore": "अधिकतम या पुनर्स्थापित करें",
"Exit Parallel Read": "पैरालल रीड से बाहर निकलें",
"Enter Parallel Read": "पैरालल रीड में प्रवेश करें",
"Zoom Level": "ज़ूम स्तर",
"Zoom Out": "ज़ूम आउट",
"Reset Zoom": "ज़ूम रीसेट करें",
"Zoom In": "ज़ूम इन",
"Zoom Mode": "ज़ूम मोड",
"Single Page": "एकल पृष्ठ",
"Auto Spread": "स्वचालित फैलाव",
"Fit Page": "पृष्ठ में फिट करें",
"Fit Width": "चौड़ाई में फिट करें",
"Failed to select directory": "निर्देशिका का चयन करने में विफल",
"The new data directory must be different from the current one.": "नया डेटा निर्देशिका वर्तमान से भिन्न होना चाहिए।",
"Migration failed: {{error}}": "माइग्रेशन विफल: {{error}}",
"Change Data Location": "डेटा स्थान बदलें",
"Current Data Location": "वर्तमान डेटा स्थान",
"Total size: {{size}}": "कुल आकार: {{size}}",
"Calculating file info...": "फ़ाइल जानकारी की गणना कर रहे हैं...",
"New Data Location": "नया डेटा स्थान",
"Choose Different Folder": "विभिन्न फ़ोल्डर चुनें",
"Choose New Folder": "नया फ़ोल्डर चुनें",
"Migrating data...": "डेटा माइग्रेट कर रहे हैं...",
"Copying: {{file}}": "कॉपी कर रहे हैं: {{file}}",
"{{current}} of {{total}} files": "{{current}} में से {{total}} फ़ाइलें",
"Migration completed successfully!": "माइग्रेशन सफलतापूर्वक पूरा हुआ!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "आपका डेटा नए स्थान पर स्थानांतरित कर दिया गया है। कृपया प्रक्रिया को पूरा करने के लिए एप्लिकेशन को पुनरारंभ करें।",
"Migration failed": "माइग्रेशन विफल",
"Important Notice": "महत्वपूर्ण सूचना",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "यह आपके सभी ऐप डेटा को नए स्थान पर स्थानांतरित कर देगा। सुनिश्चित करें कि गंतव्य में पर्याप्त खाली स्थान है।",
"Restart App": "एप्लिकेशन पुनरारंभ करें",
"Start Migration": "माइग्रेशन प्रारंभ करें",
"Advanced Settings": "उन्नत सेटिंग्स",
"File count: {{size}}": "फ़ाइलों की संख्या: {{size}}",
"Background Read Aloud": "पृष्ठभूमि में वाचन",
"Ready to read aloud": "उच्च स्वर में पढ़ने के लिए तैयार",
"Read Aloud": "उच्च स्वर में पढ़ें"
}
@@ -559,5 +559,41 @@
"Toggle Translation": "Toggel Terjemahan",
"Translation Disabled": "Terjemahan Dinonaktifkan",
"Minimize": "Minimalkan",
"Maximize or Restore": "Maksimalkan atau Pulihkan"
"Maximize or Restore": "Maksimalkan atau Pulihkan",
"Exit Parallel Read": "Keluar dari Baca Paralel",
"Enter Parallel Read": "Masuk ke Baca Paralel",
"Zoom Level": "Level Zoom",
"Zoom Out": "Perbesar Zoom",
"Reset Zoom": "Reset Zoom",
"Zoom In": "Perkecil Zoom",
"Zoom Mode": "Mode Zoom",
"Single Page": "Halaman Tunggal",
"Auto Spread": "Penyebaran Otomatis",
"Fit Page": "Sesuaikan Halaman",
"Fit Width": "Sesuaikan Lebar",
"Failed to select directory": "Gagal memilih direktori",
"The new data directory must be different from the current one.": "Direktori data baru harus berbeda dari yang saat ini.",
"Migration failed: {{error}}": "Migrasi gagal: {{error}}",
"Change Data Location": "Ubah Lokasi Data",
"Current Data Location": "Lokasi Data Saat Ini",
"Total size: {{size}}": "Ukuran total: {{size}}",
"Calculating file info...": "Menghitung informasi file...",
"New Data Location": "Lokasi Data Baru",
"Choose Different Folder": "Pilih Folder Berbeda",
"Choose New Folder": "Pilih Folder Baru",
"Migrating data...": "Migrasi data...",
"Copying: {{file}}": "Menyalin: {{file}}",
"{{current}} of {{total}} files": "{{current}} dari {{total}} file",
"Migration completed successfully!": "Migrasi selesai dengan sukses!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Data Anda telah dipindahkan ke lokasi baru. Silakan restart aplikasi untuk menyelesaikan proses.",
"Migration failed": "Migrasi gagal",
"Important Notice": "Pemberitahuan Penting",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Ini akan memindahkan semua data aplikasi Anda ke lokasi baru. Pastikan tujuan memiliki cukup ruang kosong.",
"Restart App": "Restart Aplikasi",
"Start Migration": "Mulai Migrasi",
"Advanced Settings": "Pengaturan Lanjutan",
"File count: {{size}}": "Jumlah file: {{size}}",
"Background Read Aloud": "Bacakan Latar Belakang",
"Ready to read aloud": "Siap untuk dibacakan",
"Read Aloud": "Bacakan"
}
@@ -567,5 +567,41 @@
"Toggle Translation": "Toggel Terjemahan",
"Translation Disabled": "Terjemahan Dinonaktifkan",
"Minimize": "Minimalkan",
"Maximize or Restore": "Maksimalkan atau Pulihkan"
"Maximize or Restore": "Maksimalkan atau Pulihkan",
"Exit Parallel Read": "Esci dalla lettura parallela",
"Enter Parallel Read": "Entra nella lettura parallela",
"Zoom Level": "Livello di zoom",
"Zoom Out": "Riduci zoom",
"Reset Zoom": "Ripristina zoom",
"Zoom In": "Inganna zoom",
"Zoom Mode": "Modalità zoom",
"Single Page": "Pagina singola",
"Auto Spread": "Distribuzione automatica",
"Fit Page": "Adatta pagina",
"Fit Width": "Adatta larghezza",
"Failed to select directory": "Impossibile selezionare la directory",
"The new data directory must be different from the current one.": "La nuova directory dei dati deve essere diversa da quella attuale.",
"Migration failed: {{error}}": "Migrzione fallita: {{error}}",
"Change Data Location": "Cambia posizione dei dati",
"Current Data Location": "Posizione attuale dei dati",
"Total size: {{size}}": "Dimensione totale: {{size}}",
"Calculating file info...": "Calcolo informazioni file...",
"New Data Location": "Nuova posizione dei dati",
"Choose Different Folder": "Scegli una cartella diversa",
"Choose New Folder": "Scegli una nuova cartella",
"Migrating data...": "Migrando dati...",
"Copying: {{file}}": "Copia in corso: {{file}}",
"{{current}} of {{total}} files": "{{current}} di {{total}} file",
"Migration completed successfully!": "Migrzione completata con successo!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "I tuoi dati sono stati spostati nella nuova posizione. Riavvia l'applicazione per completare il processo.",
"Migration failed": "Migrzione fallita",
"Important Notice": "Avviso Importante",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Questo sposterà tutti i dati dell'app nella nuova posizione. Assicurati che la destinazione abbia spazio libero sufficiente.",
"Restart App": "Riavvia App",
"Start Migration": "Inizia Migrazione",
"Advanced Settings": "Impostazioni Avanzate",
"File count: {{size}}": "Conteggio file: {{size}}",
"Background Read Aloud": "Riproduzione in background",
"Ready to read aloud": "Pronto per la lettura ad alta voce",
"Read Aloud": "Leggi ad alta voce"
}
@@ -559,5 +559,41 @@
"Toggle Translation": "翻訳の切り替え",
"Translation Disabled": "翻訳が無効化されました",
"Minimize": "最小化",
"Maximize or Restore": "最大化または復元"
"Maximize or Restore": "最大化または復元",
"Exit Parallel Read": "並行読書を終了",
"Enter Parallel Read": "並行読書に入る",
"Zoom Level": "ズームレベル",
"Zoom Out": "ズームアウト",
"Reset Zoom": "ズームリセット",
"Zoom In": "ズームイン",
"Zoom Mode": "ズームモード",
"Single Page": "シングルページ",
"Auto Spread": "自動スプレッド",
"Fit Page": "ページに合わせる",
"Fit Width": "幅に合わせる",
"Failed to select directory": "ディレクトリの選択に失敗しました",
"The new data directory must be different from the current one.": "新しいデータディレクトリは現在のものと異なる必要があります。",
"Migration failed: {{error}}": "移行に失敗しました: {{error}}",
"Change Data Location": "データの場所を変更",
"Current Data Location": "現在のデータの場所",
"Total size: {{size}}": "合計サイズ: {{size}}",
"Calculating file info...": "ファイル情報を計算中...",
"New Data Location": "新しいデータの場所",
"Choose Different Folder": "別のフォルダーを選択",
"Choose New Folder": "新しいフォルダーを選択",
"Migrating data...": "データを移行中...",
"Copying: {{file}}": "コピー中: {{file}}",
"{{current}} of {{total}} files": "{{current}} / {{total}} ファイル",
"Migration completed successfully!": "移行が成功しました!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "データは新しい場所に移動されました。プロセスを完了するにはアプリケーションを再起動してください。",
"Migration failed": "移行に失敗しました",
"Important Notice": "重要なお知らせ",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "これにより、すべてのアプリデータが新しい場所に移動されます。宛先に十分な空き容量があることを確認してください。",
"Restart App": "アプリを再起動",
"Start Migration": "移行を開始",
"Advanced Settings": "詳細設定",
"File count: {{size}}": "ファイル数: {{size}}",
"Background Read Aloud": "バックグラウンド読み上げ",
"Ready to read aloud": "読み上げの準備ができました",
"Read Aloud": "読み上げ"
}
@@ -559,5 +559,41 @@
"Toggle Translation": "번역 전환",
"Translation Disabled": "번역 비활성화",
"Minimize": "최소화",
"Maximize or Restore": "최대화 또는 복원"
"Maximize or Restore": "최대화 또는 복원",
"Exit Parallel Read": "병렬 읽기 종료",
"Enter Parallel Read": "병렬 읽기 시작",
"Zoom Level": "확대/축소 수준",
"Zoom Out": "축소",
"Reset Zoom": "줌 재설정",
"Zoom In": "확대",
"Zoom Mode": "줌 모드",
"Single Page": "단일 페이지",
"Auto Spread": "자동 스프레드",
"Fit Page": "페이지에 맞춤",
"Fit Width": "너비에 맞춤",
"Failed to select directory": "디렉토리 선택 실패",
"The new data directory must be different from the current one.": "새 데이터 디렉토리는 현재 디렉토리와 달라야 합니다.",
"Migration failed: {{error}}": "마이그레이션 실패: {{error}}",
"Change Data Location": "데이터 위치 변경",
"Current Data Location": "현재 데이터 위치",
"Total size: {{size}}": "총 크기: {{size}}",
"Calculating file info...": "파일 정보 계산 중...",
"New Data Location": "새 데이터 위치",
"Choose Different Folder": "다른 폴더 선택",
"Choose New Folder": "새 폴더 선택",
"Migrating data...": "데이터 마이그레이션 중...",
"Copying: {{file}}": "복사 중: {{file}}",
"{{current}} of {{total}} files": "{{current}} / {{total}} 파일",
"Migration completed successfully!": "마이그레이션이 성공적으로 완료되었습니다!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "데이터가 새 위치로 이동되었습니다. 프로세스를 완료하려면 애플리케이션을 재시작하십시오.",
"Migration failed": "마이그레이션 실패",
"Important Notice": "중요한 공지",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "이 작업은 모든 앱 데이터를 새 위치로 이동합니다. 대상에 충분한 여유 공간이 있는지 확인하십시오.",
"Restart App": "앱 재시작",
"Start Migration": "마이그레이션 시작",
"Advanced Settings": "고급 설정",
"File count: {{size}}": "파일 수: {{size}}",
"Background Read Aloud": "백그라운드 음성 읽기",
"Ready to read aloud": "읽기 준비 완료",
"Read Aloud": "음성 읽기"
}
@@ -563,5 +563,41 @@
"Toggle Translation": "Vertaling Wisselen",
"Translation Disabled": "Vertaling Uitgeschakeld",
"Minimize": "Minimaliseren",
"Maximize or Restore": "Maximaliseren of Herstellen"
"Maximize or Restore": "Maximaliseren of Herstellen",
"Exit Parallel Read": "Parallel Lezen Afsluiten",
"Enter Parallel Read": "Parallel Lezen Starten",
"Zoom Level": "Zoomniveau",
"Zoom Out": "Zoom Verkleinen",
"Reset Zoom": "Zoom Reset",
"Zoom In": "Zoom Vergroten",
"Zoom Mode": "Zoom Modus",
"Single Page": "Enkele Pagina",
"Auto Spread": "Automatische Verspreiding",
"Fit Page": "Pagina Aanpassen",
"Fit Width": "Breedte Aanpassen",
"Failed to select directory": "Selecteren van map is mislukt",
"The new data directory must be different from the current one.": "De nieuwe gegevensmap moet anders zijn dan de huidige.",
"Migration failed: {{error}}": "Migratie mislukt: {{error}}",
"Change Data Location": "Gegevenslocatie Wijzigen",
"Current Data Location": "Huidige Gegevenslocatie",
"Total size: {{size}}": "Totale grootte: {{size}}",
"Calculating file info...": "Bestandinformatie wordt berekend...",
"New Data Location": "Nieuwe Gegevenslocatie",
"Choose Different Folder": "Kies Een Andere Map",
"Choose New Folder": "Kies Nieuwe Map",
"Migrating data...": "Gegevens worden gemigreerd...",
"Copying: {{file}}": "Kopiëren: {{file}}",
"{{current}} of {{total}} files": "{{current}} van {{total}} bestanden",
"Migration completed successfully!": "Migratie succesvol voltooid!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Uw gegevens zijn naar de nieuwe locatie verplaatst. Start de applicatie opnieuw op om het proces te voltooien.",
"Migration failed": "Migratie mislukt",
"Important Notice": "Belangrijke Kennisgeving",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Dit zal al uw app-gegevens naar de nieuwe locatie verplaatsen. Zorg ervoor dat de bestemming voldoende vrije ruimte heeft.",
"Restart App": "Herstart App",
"Start Migration": "Start Migratie",
"Advanced Settings": "Geavanceerde Instellingen",
"File count: {{size}}": "Bestandsaantal: {{size}}",
"Background Read Aloud": "Achtergrond Voorlezen",
"Ready to read aloud": "Klaar om voor te lezen",
"Read Aloud": "Voorlezen"
}
@@ -571,5 +571,41 @@
"Toggle Translation": "Włącz/Wyłącz tłumaczenie",
"Translation Disabled": "Tłumaczenie wyłączone",
"Minimize": "Minimalizuj",
"Maximize or Restore": "Maksymalizuj lub Przywróć"
"Maximize or Restore": "Maksymalizuj lub Przywróć",
"Exit Parallel Read": "Wyjdź z czytania równoległego",
"Enter Parallel Read": "Wejdź w czytanie równoległe",
"Zoom Level": "Poziom powiększenia",
"Zoom Out": "Zoom Wydłuż",
"Reset Zoom": "Resetuj Zoom",
"Zoom In": "Zoom Skróć",
"Zoom Mode": "Tryb Zoom",
"Single Page": "Pojedyncza Strona",
"Auto Spread": "Automatyczne Rozprzestrzenianie",
"Fit Page": "Dopasuj Stronę",
"Fit Width": "Dopasuj Szerokość",
"Failed to select directory": "Nie udało się wybrać katalogu",
"The new data directory must be different from the current one.": "Nowy katalog danych musi różnić się od obecnego.",
"Migration failed: {{error}}": "Migracja nie powiodła się: {{error}}",
"Change Data Location": "Zmień lokalizację danych",
"Current Data Location": "Bieżąca lokalizacja danych",
"Total size: {{size}}": "Całkowity rozmiar: {{size}}",
"Calculating file info...": "Obliczanie informacji o pliku...",
"New Data Location": "Nowa lokalizacja danych",
"Choose Different Folder": "Wybierz inny folder",
"Choose New Folder": "Wybierz nowy folder",
"Migrating data...": "Migracja danych...",
"Copying: {{file}}": "Kopiowanie: {{file}}",
"{{current}} of {{total}} files": "{{current}} z {{total}} plików",
"Migration completed successfully!": "Migracja zakończona pomyślnie!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Twoje dane zostały przeniesione do nowej lokalizacji. Proszę zrestartować aplikację, aby zakończyć proces.",
"Migration failed": "Migracja nie powiodła się",
"Important Notice": "Ważne powiadomienie",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "To przeniesie wszystkie dane aplikacji do nowej lokalizacji. Upewnij się, że docelowy folder ma wystarczająco dużo wolnego miejsca.",
"Restart App": "Zrestartuj aplikację",
"Start Migration": "Rozpocznij migrację",
"Advanced Settings": "Ustawienia zaawansowane",
"File count: {{size}}": "Liczba plików: {{size}}",
"Background Read Aloud": "Czytanie na głos w tle",
"Ready to read aloud": "Gotowy do przeczytania na głos",
"Read Aloud": "Czytaj na głos"
}
@@ -567,5 +567,41 @@
"Toggle Translation": "Alternar Tradução",
"Translation Disabled": "Tradução Desativada",
"Minimize": "Minimizar",
"Maximize or Restore": "Maximizar ou Restaurar"
"Maximize or Restore": "Maximizar ou Restaurar",
"Exit Parallel Read": "Sair da Leitura Paralela",
"Enter Parallel Read": "Entrar na Leitura Paralela",
"Zoom Level": "Nivel de Zoom",
"Zoom Out": "Reduzir Zoom",
"Reset Zoom": "Redefinir Zoom",
"Zoom In": "Aumentar Zoom",
"Zoom Mode": "Modo de Zoom",
"Single Page": "Página Única",
"Auto Spread": "Espalhamento Automático",
"Fit Page": "Ajustar Página",
"Fit Width": "Ajustar Largura",
"Failed to select directory": "Falha ao selecionar diretório",
"The new data directory must be different from the current one.": "O novo diretório de dados deve ser diferente do atual.",
"Migration failed: {{error}}": "A migração falhou: {{error}}",
"Change Data Location": "Alterar Localização dos Dados",
"Current Data Location": "Localização Atual dos Dados",
"Total size: {{size}}": "Tamanho total: {{size}}",
"Calculating file info...": "Calculando informações do arquivo...",
"New Data Location": "Nova Localização dos Dados",
"Choose Different Folder": "Escolher Pasta Diferente",
"Choose New Folder": "Escolher Nova Pasta",
"Migrating data...": "Migrando dados...",
"Copying: {{file}}": "Copiando: {{file}}",
"{{current}} of {{total}} files": "{{current}} de {{total}} arquivos",
"Migration completed successfully!": "Migração concluída com sucesso!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Seus dados foram movidos para a nova localização. Reinicie o aplicativo para concluir o processo.",
"Migration failed": "Migração falhou",
"Important Notice": "Aviso Importante",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Isso moverá todos os dados do seu aplicativo para a nova localização. Certifique-se de que o destino tenha espaço livre suficiente.",
"Restart App": "Reiniciar Aplicativo",
"Start Migration": "Iniciar Migração",
"Advanced Settings": "Configurações Avançadas",
"File count: {{size}}": "Contagem de arquivos: {{size}}",
"Background Read Aloud": "Leitura em Segundo Plano",
"Ready to read aloud": "Pronto para leitura em voz alta",
"Read Aloud": "Ler em Voz Alta"
}
@@ -571,5 +571,41 @@
"Toggle Translation": "Переключить перевод",
"Translation Disabled": "Перевод отключен",
"Minimize": "Свернуть",
"Maximize or Restore": "Максимизировать или восстановить"
"Maximize or Restore": "Максимизировать или восстановить",
"Exit Parallel Read": "Выйти из параллельного чтения",
"Enter Parallel Read": "Войти в параллельное чтение",
"Zoom Level": "Уровень масштабирования",
"Zoom Out": "Уменьшить масштаб",
"Reset Zoom": "Сбросить масштаб",
"Zoom In": "Увеличить масштаб",
"Zoom Mode": "Режим масштабирования",
"Single Page": "Одна страница",
"Auto Spread": "Автоматическое растяжение",
"Fit Page": "Подогнать страницу",
"Fit Width": "Подогнать ширину",
"Failed to select directory": "Не удалось выбрать каталог",
"The new data directory must be different from the current one.": "Новая директория данных должна отличаться от текущей.",
"Migration failed: {{error}}": "Ошибка миграции: {{error}}",
"Change Data Location": "Изменить расположение данных",
"Current Data Location": "Текущее расположение данных",
"Total size: {{size}}": "Общий размер: {{size}}",
"Calculating file info...": "Вычисление информации о файле...",
"New Data Location": "Новое расположение данных",
"Choose Different Folder": "Выбрать другую папку",
"Choose New Folder": "Выбрать новую папку",
"Migrating data...": "Миграция данных...",
"Copying: {{file}}": "Копирование: {{file}}",
"{{current}} of {{total}} files": "{{current}} из {{total}} файлов",
"Migration completed successfully!": "Миграция завершена успешно!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Ваши данные были перемещены в новое расположение. Пожалуйста, перезапустите приложение, чтобы завершить процесс.",
"Migration failed": "Ошибка миграции",
"Important Notice": "Важное уведомление",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Это переместит все ваши данные приложения в новое расположение. Убедитесь, что в целевом месте достаточно свободного места.",
"Restart App": "Перезапустить приложение",
"Start Migration": "Начать миграцию",
"Advanced Settings": "Расширенные настройки",
"File count: {{size}}": "Количество файлов: {{size}}",
"Background Read Aloud": "Озвучивание в фоне",
"Ready to read aloud": "Готов к чтению вслух",
"Read Aloud": "Читать вслух"
}
@@ -563,5 +563,41 @@
"Toggle Translation": "පරිවර්තනය ස්ථානය වෙනස් කරන්න",
"Translation Disabled": "පරිවර්තනය අක්‍රීය කර ඇත",
"Minimize": "අඩු කරන්න",
"Maximize or Restore": "ඉහළ නැංවීම හෝ ප්‍රතිසංස්කරණය"
"Maximize or Restore": "ඉහළ නැංවීම හෝ ප්‍රතිසංස්කරණය",
"Exit Parallel Read": "සමාන්තර කියවීමෙන් පිටවන්න",
"Enter Parallel Read": "සමාන්තර කියවීමේදී පිවිසෙන්න",
"Zoom Level": "සංයුතිය මට්ටම",
"Zoom Out": "සංයුතිය අඩු කරන්න",
"Reset Zoom": "සංයුතිය නැවත සකසන්න",
"Zoom In": "සංයුතිය වැඩි කරන්න",
"Zoom Mode": "සංයුතිය ආකාරය",
"Single Page": "එක් පිටුව",
"Auto Spread": "ස්වයංක්‍රීය ව්‍යාප්තිය",
"Fit Page": "පිටුවට ගැලපෙන ලෙස",
"Fit Width": "පැතිකඩට ගැලපෙන ලෙස",
"Failed to select directory": "ෆෝල්ඩරය තෝරා ගැනීමට අසමත්",
"The new data directory must be different from the current one.": "නව දත්ත ෆෝල්ඩරය වර්තමාන ෆෝල්ඩරයෙන් වෙනස් විය යුතුය.",
"Migration failed: {{error}}": "මැතිවරණය අසමත් විය: {{error}}",
"Change Data Location": "දත්ත ස්ථානය වෙනස් කරන්න",
"Current Data Location": "වර්තමාන දත්ත ස්ථානය",
"Total size: {{size}}": "මුළු ප්‍රමාණය: {{size}}",
"Calculating file info...": "ගොනු තොරතුරු ගණනය කරමින්...",
"New Data Location": "නව දත්ත ස්ථානය",
"Choose Different Folder": "වෙනත් ෆෝල්ඩරයක් තෝරන්න",
"Choose New Folder": "නව ෆෝල්ඩරයක් තෝරන්න",
"Migrating data...": "දත්ත මැතිවරණය කරමින්...",
"Copying: {{file}}": "පිටපත් කරමින්: {{file}}",
"{{current}} of {{total}} files": "{{current}}/{{total}} ගොනු",
"Migration completed successfully!": "මැතිවරණය සාර්ථකව නිමවිය!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "ඔබගේ දත්ත නව ස්ථානයට මාරු කර ඇත. ක්‍රියාවලිය සම්පූර්ණ කිරීමට කරුණාකර යෙදුම නැවත ආරම්භ කරන්න.",
"Migration failed": "මැතිවරණය අසමත් විය",
"Important Notice": "මහත් අවධානයක්",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "මෙය ඔබගේ යෙදුම් දත්ත සියල්ල නව ස්ථානයට මාරු කරනු ඇත. ගමන් මාර්ගය ප්‍රමාණවත් නිදහස් ස්ථානයක් ඇති බවට සහතික වන්න.",
"Restart App": "යෙදුම නැවත ආරම්භ කරන්න",
"Start Migration": "මැතිවරණය ආරම්භ කරන්න",
"Advanced Settings": "උසස් සැකසුම්",
"File count: {{size}}": "ගොනු ගණන: {{size}}",
"Background Read Aloud": "පසුබිම් පවසා කියවීම",
"Ready to read aloud": "වාදනය කිරීමට සූදානම්",
"Read Aloud": "පවසා කියවීම"
}
@@ -563,5 +563,41 @@
"Toggle Translation": "மொழிபெயர்ப்பை மாற்றவும்",
"Translation Disabled": "மொழிபெயர்ப்பு முடக்கப்பட்டுள்ளது",
"Minimize": "சிறிது செய்யவும்",
"Maximize or Restore": "அதிகரிக்கவும் அல்லது மீட்டமைக்கவும்"
"Maximize or Restore": "அதிகரிக்கவும் அல்லது மீட்டமைக்கவும்",
"Exit Parallel Read": "இணை வாசிப்பை வெளியேறு",
"Enter Parallel Read": "இணை வாசிப்பில் நுழையவும்",
"Zoom Level": "ஜூம் நிலை",
"Zoom Out": "ஜூம் குறைக்கவும்",
"Reset Zoom": "ஜூம் மீட்டமைக்கவும்",
"Zoom In": "ஜூம் அதிகரிக்கவும்",
"Zoom Mode": "ஜூம் முறை",
"Single Page": "ஒரு பக்கம்",
"Auto Spread": "தானாக பரவல்",
"Fit Page": "பக்கத்திற்கு பொருந்தும்",
"Fit Width": "அகலத்திற்கு பொருந்தும்",
"Failed to select directory": "கோப்புறையைத் தேர்வுசெய்ய முடியவில்லை",
"The new data directory must be different from the current one.": "புதிய தரவுக் கோப்புறை தற்போதையதைப் போலவே இருக்கக்கூடாது.",
"Migration failed: {{error}}": "மொத்தமாக்கல் தோல்வியுற்றது: {{error}}",
"Change Data Location": "தரவுப் இடத்தை மாற்றவும்",
"Current Data Location": "தற்போதைய தரவுப் இடம்",
"Total size: {{size}}": "மொத்த அளவு: {{size}}",
"Calculating file info...": "கோப்பு தகவலை கணக்கிடுகிறது...",
"New Data Location": "புதிய தரவுப் இடம்",
"Choose Different Folder": "வேறுபட்ட கோப்புறையைத் தேர்ந்தெடுக்கவும்",
"Choose New Folder": "புதிய கோப்புறையைத் தேர்ந்தெடுக்கவும்",
"Migrating data...": "தரவை மாறுகிறது...",
"Copying: {{file}}": "பகுப்பாய்வு: {{file}}",
"{{current}} of {{total}} files": "{{current}}/{{total}} கோப்புகள்",
"Migration completed successfully!": "மொத்தமாக்கல் வெற்றிகரமாக முடிந்தது!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "உங்கள் தரவுகள் புதிய இடத்திற்கு மாறியுள்ளன. செயலியை மீண்டும் தொடங்கவும்.",
"Migration failed": "மொத்தமாக்கல் தோல்வியுற்றது",
"Important Notice": "முக்கிய அறிவிப்பு",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "இது உங்கள் செயலியின் அனைத்து தரவுகளையும் புதிய இடத்திற்கு நகர்த்தும். இலக்கு இடத்தில் போதுமான இலவச இடம் உள்ளதா என்பதை உறுதிப்படுத்தவும்.",
"Restart App": "செயலியை மீண்டும் தொடங்கவும்",
"Start Migration": "மொத்தமாக்கலை தொடங்கவும்",
"Advanced Settings": "மேம்பட்ட அமைப்புகள்",
"File count: {{size}}": "கோப்பு எண்ணிக்கை: {{size}}",
"Background Read Aloud": "பின்னணி உரை வாசிப்பு",
"Ready to read aloud": "வாசிக்க தயாராக உள்ளது",
"Read Aloud": "உரை வாசிப்பு"
}
@@ -559,5 +559,41 @@
"Toggle Translation": "เปลี่ยนการแปล",
"Translation Disabled": "การแปลถูกปิดใช้งาน",
"Minimize": "ย่อ",
"Maximize or Restore": "ขยายหรือคืนค่า"
"Maximize or Restore": "ขยายหรือคืนค่า",
"Exit Parallel Read": "ออกจากการอ่านคู่ขนาน",
"Enter Parallel Read": "เริ่มการอ่านคู่ขนาน",
"Zoom Level": "ระดับการซูม",
"Zoom Out": "ลดระดับการซูม",
"Reset Zoom": "รีเซ็ตระดับการซูม",
"Zoom In": "เพิ่มระดับการซูม",
"Zoom Mode": "โหมดการซูม",
"Single Page": "หน้าเดียว",
"Auto Spread": "การกระจายอัตโนมัติ",
"Fit Page": "พอดีกับหน้า",
"Fit Width": "พอดีกับความกว้าง",
"Failed to select directory": "ไม่สามารถเลือกไดเรกทอรีได้",
"The new data directory must be different from the current one.": "ไดเรกทอรีข้อมูลใหม่จะต้องแตกต่างจากไดเรกทอรีปัจจุบัน",
"Migration failed: {{error}}": "การย้ายข้อมูลล้มเหลว: {{error}}",
"Change Data Location": "เปลี่ยนตำแหน่งข้อมูล",
"Current Data Location": "ตำแหน่งข้อมูลปัจจุบัน",
"Total size: {{size}}": "ขนาดรวม: {{size}}",
"Calculating file info...": "กำลังคำนวณข้อมูลไฟล์...",
"New Data Location": "ตำแหน่งข้อมูลใหม่",
"Choose Different Folder": "เลือกโฟลเดอร์ที่แตกต่าง",
"Choose New Folder": "เลือกโฟลเดอร์ใหม่",
"Migrating data...": "กำลังย้ายข้อมูล...",
"Copying: {{file}}": "กำลังคัดลอก: {{file}}",
"{{current}} of {{total}} files": "{{current}} จาก {{total}} ไฟล์",
"Migration completed successfully!": "การย้ายข้อมูลเสร็จสมบูรณ์!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "ข้อมูลของคุณได้ถูกย้ายไปยังตำแหน่งใหม่แล้ว กรุณารีสตาร์ทแอปพลิเคชันเพื่อทำให้กระบวนการเสร็จสมบูรณ์",
"Migration failed": "การย้ายข้อมูลล้มเหลว",
"Important Notice": "ประกาศสำคัญ",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "สิ่งนี้จะย้ายข้อมูลแอปทั้งหมดของคุณไปยังตำแหน่งใหม่ ตรวจสอบให้แน่ใจว่าตำแหน่งปลายทางมีพื้นที่ว่างเพียงพอ",
"Restart App": "รีสตาร์ทแอป",
"Start Migration": "เริ่มการย้ายข้อมูล",
"Advanced Settings": "การตั้งค่าขั้นสูง",
"File count: {{size}}": "จำนวนไฟล์: {{size}}",
"Background Read Aloud": "อ่านออกเสียงเบื้องหลัง",
"Ready to read aloud": "พร้อมที่จะอ่านออกเสียง",
"Read Aloud": "อ่านออกเสียง"
}
@@ -563,5 +563,41 @@
"Toggle Translation": "Çeviriyi Değiştir",
"Translation Disabled": "Çeviri Devre Dışı",
"Minimize": "Küçült",
"Maximize or Restore": "Büyüt veya Geri Yükle"
"Maximize or Restore": "Büyüt veya Geri Yükle",
"Exit Parallel Read": "Paralel Okumadan Çık",
"Enter Parallel Read": "Paralel Okumaya Gir",
"Zoom Level": "Yakınlaştırma Düzeyi",
"Zoom Out": "Yakınlaştırmayı Kaldır",
"Reset Zoom": "Yakınlaştırmayı Sıfırla",
"Zoom In": "Yakınlaştırmayı Artır",
"Zoom Mode": "Yakınlaştırma Modu",
"Single Page": "Tek Sayfa",
"Auto Spread": "Otomatik Yayılma",
"Fit Page": "Sayfaya Sığdır",
"Fit Width": "Genişliğe Sığdır",
"Failed to select directory": "Dizini seçme başarısız oldu",
"The new data directory must be different from the current one.": "Yeni veri dizini mevcut olanla farklı olmalıdır.",
"Migration failed: {{error}}": "Taşıma işlemi başarısız oldu: {{error}}",
"Change Data Location": "Veri Konumunu Değiştir",
"Current Data Location": "Mevcut Veri Konumu",
"Total size: {{size}}": "Toplam boyut: {{size}}",
"Calculating file info...": "Dosya bilgileri hesaplanıyor...",
"New Data Location": "Yeni Veri Konumu",
"Choose Different Folder": "Farklı Klasör Seç",
"Choose New Folder": "Yeni Klasör Seç",
"Migrating data...": "Veri taşınıyor...",
"Copying: {{file}}": "Kopyalanıyor: {{file}}",
"{{current}} of {{total}} files": "{{current}} / {{total}} dosya",
"Migration completed successfully!": "Taşıma işlemi başarıyla tamamlandı!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Verileriniz yeni konuma taşındı. Lütfen işlemi tamamlamak için uygulamayı yeniden başlatın.",
"Migration failed": "Taşıma işlemi başarısız oldu",
"Important Notice": "Önemli Duyuru",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Bu, tüm uygulama verilerinizi yeni konuma taşıyacaktır. Hedefin yeterli boş alana sahip olduğundan emin olun.",
"Restart App": "Uygulamayı Yeniden Başlat",
"Start Migration": "Taşımayı Başlat",
"Advanced Settings": "Gelişmiş Ayarlar",
"File count: {{size}}": "Dosya sayısı: {{size}}",
"Background Read Aloud": "Arka Planda Sesli Okuma",
"Ready to read aloud": "Sesli okumaya hazır",
"Read Aloud": "Sesli Oku"
}
@@ -571,5 +571,41 @@
"Toggle Translation": "Перемкнути переклад",
"Translation Disabled": "Переклад вимкнено",
"Minimize": "Згорнути",
"Maximize or Restore": "Збільшити або відновити"
"Maximize or Restore": "Збільшити або відновити",
"Exit Parallel Read": "Вийти з паралельного читання",
"Enter Parallel Read": "Войти в параллельное чтение",
"Zoom Level": "Рівень масштабування",
"Zoom Out": "Зменшити масштаб",
"Reset Zoom": "Скинути масштаб",
"Zoom In": "Збільшити масштаб",
"Zoom Mode": "Режим масштабування",
"Single Page": "Одна сторінка",
"Auto Spread": "Автоматичне розповсюдження",
"Fit Page": "Підганяти під сторінку",
"Fit Width": "Підганяти під ширину",
"Failed to select directory": "Не вдалося вибрати каталог",
"The new data directory must be different from the current one.": "Новий каталог даних повинен відрізнятися від поточного.",
"Migration failed: {{error}}": "Переміщення не вдалося: {{error}}",
"Change Data Location": "Змінити розташування даних",
"Current Data Location": "Поточне розташування даних",
"Total size: {{size}}": "Загальний розмір: {{size}}",
"Calculating file info...": "Обчислення інформації про файл...",
"New Data Location": "Нове розташування даних",
"Choose Different Folder": "Вибрати іншу папку",
"Choose New Folder": "Вибрати нову папку",
"Migrating data...": "Переміщення даних...",
"Copying: {{file}}": "Копіювання: {{file}}",
"{{current}} of {{total}} files": "{{current}} з {{total}} файлів",
"Migration completed successfully!": "Переміщення завершено успішно!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Ваші дані були переміщені до нового розташування. Будь ласка, перезапустіть програму, щоб завершити процес.",
"Migration failed": "Переміщення не вдалося",
"Important Notice": "Важливе повідомлення",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Це перемістить всі ваші дані програми до нового розташування. Переконайтеся, що в призначенні достатньо вільного місця.",
"Restart App": "Перезапустити програму",
"Start Migration": "Почати переміщення",
"Advanced Settings": "Розширені налаштування",
"File count: {{size}}": "Кількість файлів: {{size}}",
"Background Read Aloud": "Озвучування у фоновому режимі",
"Ready to read aloud": "Готовий до читання вголос",
"Read Aloud": "Читати вголос"
}
@@ -559,5 +559,41 @@
"Toggle Translation": "Chuyển đổi dịch thuật",
"Translation Disabled": "Dịch thuật đã tắt",
"Minimize": "Thu nhỏ",
"Maximize or Restore": "Tối đa hóa hoặc Khôi phục"
"Maximize or Restore": "Tối đa hóa hoặc Khôi phục",
"Exit Parallel Read": "Thoát Đọc Song Song",
"Enter Parallel Read": "Vào Đọc Song Song",
"Zoom Level": "Độ phóng to",
"Zoom Out": "Thu nhỏ",
"Reset Zoom": "Đặt lại độ phóng to",
"Zoom In": "Phóng to",
"Zoom Mode": "Chế độ phóng to",
"Single Page": "Một trang",
"Auto Spread": "Tự động trải rộng",
"Fit Page": "Vừa trang",
"Fit Width": "Vừa chiều rộng",
"Failed to select directory": "Không thể chọn thư mục",
"The new data directory must be different from the current one.": "Thư mục dữ liệu mới phải khác với thư mục hiện tại.",
"Migration failed: {{error}}": "Di chuyển không thành công: {{error}}",
"Change Data Location": "Thay đổi vị trí dữ liệu",
"Current Data Location": "Vị trí dữ liệu hiện tại",
"Total size: {{size}}": "Tổng kích thước: {{size}}",
"Calculating file info...": "Đang tính toán thông tin tệp...",
"New Data Location": "Vị trí dữ liệu mới",
"Choose Different Folder": "Chọn thư mục khác",
"Choose New Folder": "Chọn thư mục mới",
"Migrating data...": "Đang di chuyển dữ liệu...",
"Copying: {{file}}": "Đang sao chép: {{file}}",
"{{current}} of {{total}} files": "{{current}} của {{total}} tệp",
"Migration completed successfully!": "Di chuyển hoàn tất thành công!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Dữ liệu của bạn đã được chuyển đến vị trí mới. Vui lòng khởi động lại ứng dụng để hoàn tất quá trình.",
"Migration failed": "Di chuyển không thành công",
"Important Notice": "Thông báo quan trọng",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Điều này sẽ di chuyển tất cả dữ liệu ứng dụng của bạn đến vị trí mới. Hãy chắc chắn rằng đích đến có đủ không gian trống.",
"Restart App": "Khởi động lại ứng dụng",
"Start Migration": "Bắt đầu di chuyển",
"Advanced Settings": "Cài đặt nâng cao",
"File count: {{size}}": "Số lượng tệp: {{size}}",
"Background Read Aloud": "Đọc To Nền",
"Ready to read aloud": "Sẵn sàng để đọc to",
"Read Aloud": "Đọc To"
}
@@ -559,5 +559,41 @@
"Toggle Translation": "切换翻译",
"Translation Disabled": "翻译已禁用",
"Minimize": "最小化",
"Maximize or Restore": "最大化或还原"
"Maximize or Restore": "最大化或还原",
"Exit Parallel Read": "退出并排阅读",
"Enter Parallel Read": "进入并排阅读",
"Zoom Level": "缩放级别",
"Zoom Out": "缩小",
"Reset Zoom": "重置缩放",
"Zoom In": "放大",
"Zoom Mode": "缩放模式",
"Single Page": "单页",
"Auto Spread": "自动展开",
"Fit Page": "适应页面",
"Fit Width": "适应宽度",
"Failed to select directory": "选择目录失败",
"The new data directory must be different from the current one.": "新的数据目录必须与当前目录不同。",
"Migration failed: {{error}}": "迁移失败: {{error}}",
"Change Data Location": "更改数据位置",
"Current Data Location": "当前数据位置",
"Total size: {{size}}": "总大小: {{size}}",
"Calculating file info...": "正在计算文件信息...",
"New Data Location": "新数据位置",
"Choose Different Folder": "选择不同的文件夹",
"Choose New Folder": "选择新文件夹",
"Migrating data...": "正在迁移数据...",
"Copying: {{file}}": "正在复制: {{file}}",
"{{current}} of {{total}} files": "{{current}} / {{total}} 个文件",
"Migration completed successfully!": "迁移成功完成!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "您的数据已移动到新位置。请重新启动应用程序以完成此过程。",
"Migration failed": "迁移失败",
"Important Notice": "重要通知",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "这将把您所有的应用数据移动到新位置。请确保目标位置有足够的可用空间。",
"Restart App": "重启应用",
"Start Migration": "开始迁移",
"Advanced Settings": "高级设置",
"File count: {{size}}": "文件数量: {{size}}",
"Background Read Aloud": "后台语音朗读",
"Ready to read aloud": "语音朗读已就绪",
"Read Aloud": "语音朗读"
}
@@ -559,5 +559,41 @@
"Toggle Translation": "切換翻譯",
"Translation Disabled": "翻譯已禁用",
"Minimize": "最小化",
"Maximize or Restore": "最大化或還原"
"Maximize or Restore": "最大化或還原",
"Exit Parallel Read": "退出並排閱讀",
"Enter Parallel Read": "進入並排閱讀",
"Zoom Level": "縮放級別",
"Zoom Out": "縮小",
"Reset Zoom": "重置縮放",
"Zoom In": "放大",
"Zoom Mode": "縮放模式",
"Single Page": "單頁",
"Auto Spread": "自動展開",
"Fit Page": "適應頁面",
"Fit Width": "適應寬度",
"Failed to select directory": "選擇目錄失敗",
"The new data directory must be different from the current one.": "新的數據目錄必須與當前目錄不同。",
"Migration failed: {{error}}": "遷移失敗: {{error}}",
"Change Data Location": "更改數據位置",
"Current Data Location": "當前數據位置",
"Total size: {{size}}": "總大小: {{size}}",
"Calculating file info...": "正在計算文件信息...",
"New Data Location": "新的數據位置",
"Choose Different Folder": "選擇不同的文件夾",
"Choose New Folder": "選擇新文件夾",
"Migrating data...": "正在遷移數據...",
"Copying: {{file}}": "正在複製: {{file}}",
"{{current}} of {{total}} files": "{{current}} / {{total}} 個文件",
"Migration completed successfully!": "遷移成功完成!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "您的數據已移動到新位置。請重新啟動應用程序以完成此過程。",
"Migration failed": "遷移失敗",
"Important Notice": "重要通知",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "這將把您所有的應用數據移動到新位置。請確保目標位置有足夠的可用空間。",
"Restart App": "重新啟動應用",
"Start Migration": "開始遷移",
"Advanced Settings": "高級設置",
"File count: {{size}}": "文件數量: {{size}}",
"Background Read Aloud": "背景朗讀",
"Ready to read aloud": "朗讀準備就緒",
"Read Aloud": "朗讀"
}
+14
View File
@@ -1,5 +1,19 @@
{
"releases": {
"0.9.82": {
"date": "2025-10-01",
"notes": [
"Sync: More reliable syncing between KOReader and Readest",
"MOBI: Fixed an issue where some footnotes in MOBI/AZW files were not parsed correctly",
"Storage: Added support for changing data location on Windows, macOS, Linux, and Android",
"Windows: Portable EXE version now stores all reading data in the directory of the executable",
"Android: Added background TTS support with media session controls",
"WebView: Extended compatibility down to version 92",
"Settings: Added global settings menu to the library page",
"iOS: Fixed an issue where custom theme colors were not handled correctly on older Safari versions",
"TTS: Added more languages for Edge TTS"
]
},
"0.9.81": {
"date": "2025-09-19",
"notes": [
+36 -11
View File
@@ -24,7 +24,8 @@ echo "🔢 Computed versionCode: $VERSION_CODE"
PROPERTIES_FILE="./src-tauri/gen/android/app/tauri.properties"
MANIFEST="./src-tauri/gen/android/app/src/main/AndroidManifest.xml"
PERMISSION_LINE='<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>'
INSTALL_PERMISSION_LINE='<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>'
STORAGE_PERMISSION_LINE='<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>'
if [[ ! -f "$PROPERTIES_FILE" ]]; then
echo "❌ File not found: $PROPERTIES_FILE"
@@ -38,13 +39,34 @@ mv "$tmpfile" "$PROPERTIES_FILE"
echo "✅ Updated $PROPERTIES_FILE"
ised() {
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "$@"
else
sed -i "$@"
fi
return $?
}
# --- REMOVE PERMISSION BEFORE BUILD ---
if grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST"; then
echo "🧹 Removing REQUEST_INSTALL_PACKAGES from AndroidManifest.xml"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "/REQUEST_INSTALL_PACKAGES/d" "$MANIFEST"
if ised "/REQUEST_INSTALL_PACKAGES/d" "$MANIFEST"; then
echo "✅ Successfully removed REQUEST_INSTALL_PACKAGES"
else
sed -i "/REQUEST_INSTALL_PACKAGES/d" "$MANIFEST"
echo "❌ Failed to remove REQUEST_INSTALL_PACKAGES" >&2
exit 1
fi
fi
if grep -q 'MANAGE_EXTERNAL_STORAGE' "$MANIFEST"; then
echo "🧹 Removing MANAGE_EXTERNAL_STORAGE from AndroidManifest.xml"
if ised "/MANAGE_EXTERNAL_STORAGE/d" "$MANIFEST"; then
echo "✅ Successfully removed MANAGE_EXTERNAL_STORAGE"
else
echo "❌ Failed to remove MANAGE_EXTERNAL_STORAGE" >&2
exit 1
fi
fi
@@ -54,13 +76,16 @@ pnpm tauri android build
# --- ADD PERMISSION BACK AFTER BUILD ---
if ! grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST"; then
echo "♻️ Restoring REQUEST_INSTALL_PACKAGES in AndroidManifest.xml"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "/android.permission.INTERNET/a\\
$PERMISSION_LINE
" "$MANIFEST"
else
sed -i "/android.permission.INTERNET/a \ $PERMISSION_LINE" "$MANIFEST"
fi
ised "/android.permission.INTERNET/a\\
$INSTALL_PERMISSION_LINE
" "$MANIFEST"
fi
if ! grep -q 'MANAGE_EXTERNAL_STORAGE' "$MANIFEST"; then
echo "♻️ Restoring MANAGE_EXTERNAL_STORAGE in AndroidManifest.xml"
ised "/android.permission.WRITE_EXTERNAL_STORAGE/a\\
$STORAGE_PERMISSION_LINE
" "$MANIFEST"
fi
source .env.google-play.local
+1
View File
@@ -49,6 +49,7 @@ tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2"
tauri-plugin-persisted-scope = "2"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
@@ -61,6 +61,17 @@
}
]
},
{
"identifier": "fs:scope",
"allow": [
{
"path": "**/Readest"
},
{
"path": "**/Readest/**/*"
}
]
},
{
"identifier": "http:default",
"allow": [
@@ -1,9 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
@@ -111,6 +115,12 @@
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<service
android:name="com.readest.native_tts.MediaForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -33,8 +33,8 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.browser:browser:1.8.0")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
@@ -1,10 +1,13 @@
package com.readest.native_bridge
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.view.View
import android.view.KeyEvent
import android.view.WindowInsets
@@ -13,15 +16,19 @@ import android.view.WindowInsetsController
import android.graphics.Color
import android.webkit.WebView
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.graphics.fonts.SystemFonts
import android.graphics.fonts.Font
import androidx.core.view.WindowCompat
import androidx.core.app.ActivityCompat
import androidx.core.content.FileProvider
import androidx.core.content.ContextCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.browser.customtabs.CustomTabsIntent
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.Permission
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
@@ -67,7 +74,11 @@ interface KeyDownInterceptor {
fun interceptBackKey(enabled: Boolean)
}
@TauriPlugin
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.MANAGE_EXTERNAL_STORAGE], alias = "manageStorage")
]
)
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
private var redirectScheme = "readest"
@@ -75,6 +86,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
companion object {
var pendingInvoke: Invoke? = null
private const val REQUEST_MANAGE_STORAGE = 1001
}
override fun load(webView: WebView) {
@@ -402,4 +414,53 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
@Command
fun request_manage_storage_permission(invoke: Invoke) {
val ret = JSObject()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!Environment.isExternalStorageManager()) {
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivityForResult(intent, REQUEST_MANAGE_STORAGE)
ret.put("manageStorage", "denied")
invoke.resolve(ret)
} catch (e: Exception) {
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
activity.startActivity(intent)
ret.put("manageStorage", "denied")
invoke.resolve(ret)
}
} else {
ret.put("manageStorage", "granted")
invoke.resolve(ret)
}
} else {
val readPermission = ContextCompat.checkSelfPermission(
activity,
Manifest.permission.READ_EXTERNAL_STORAGE
)
val writePermission = ContextCompat.checkSelfPermission(
activity,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
if (readPermission == PackageManager.PERMISSION_GRANTED &&
writePermission == PackageManager.PERMISSION_GRANTED) {
ret.put("manageStorage", "granted")
invoke.resolve(ret)
} else {
ActivityCompat.requestPermissions(
activity,
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
REQUEST_MANAGE_STORAGE
)
ret.put("manageStorage", "prompt")
invoke.resolve(ret)
}
}
}
}
@@ -15,6 +15,9 @@ const COMMANDS: &[&str] = &[
"iap_restore_purchases",
"get_system_color_scheme",
"get_safe_area_insets",
"request_manage_storage_permission",
"checkPermissions",
"requestPermissions",
];
fn main() {
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-checkPermissions"
description = "Enables the checkPermissions command without any pre-configured scope."
commands.allow = ["checkPermissions"]
[[permission]]
identifier = "deny-checkPermissions"
description = "Denies the checkPermissions command without any pre-configured scope."
commands.deny = ["checkPermissions"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-requestPermissions"
description = "Enables the requestPermissions command without any pre-configured scope."
commands.allow = ["requestPermissions"]
[[permission]]
identifier = "deny-requestPermissions"
description = "Denies the requestPermissions command without any pre-configured scope."
commands.deny = ["requestPermissions"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-request-manage-storage-permission"
description = "Enables the request_manage_storage_permission command without any pre-configured scope."
commands.allow = ["request_manage_storage_permission"]
[[permission]]
identifier = "deny-request-manage-storage-permission"
description = "Denies the request_manage_storage_permission command without any pre-configured scope."
commands.deny = ["request_manage_storage_permission"]
@@ -20,6 +20,9 @@ Default permissions for the plugin
- `allow-iap-restore-purchases`
- `allow-get-system-color-scheme`
- `allow-get-safe-area-insets`
- `allow-request-manage-storage-permission`
- `allow-checkPermissions`
- `allow-requestPermissions`
## Permission Table
@@ -85,6 +88,32 @@ Denies the auth_with_safari command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-checkPermissions`
</td>
<td>
Enables the checkPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-checkPermissions`
</td>
<td>
Denies the checkPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-copy-uri-to-path`
</td>
@@ -397,6 +426,58 @@ Denies the lock_screen_orientation command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-requestPermissions`
</td>
<td>
Enables the requestPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-requestPermissions`
</td>
<td>
Denies the requestPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-request-manage-storage-permission`
</td>
<td>
Enables the request_manage_storage_permission command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-request-manage-storage-permission`
</td>
<td>
Denies the request_manage_storage_permission command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-set-system-ui-visibility`
</td>
@@ -17,4 +17,7 @@ permissions = [
"allow-iap-restore-purchases",
"allow-get-system-color-scheme",
"allow-get-safe-area-insets",
"allow-request-manage-storage-permission",
"allow-checkPermissions",
"allow-requestPermissions",
]
@@ -318,6 +318,18 @@
"const": "deny-auth-with-safari",
"markdownDescription": "Denies the auth_with_safari command without any pre-configured scope."
},
{
"description": "Enables the checkPermissions command without any pre-configured scope.",
"type": "string",
"const": "allow-checkPermissions",
"markdownDescription": "Enables the checkPermissions command without any pre-configured scope."
},
{
"description": "Denies the checkPermissions command without any pre-configured scope.",
"type": "string",
"const": "deny-checkPermissions",
"markdownDescription": "Denies the checkPermissions command without any pre-configured scope."
},
{
"description": "Enables the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
@@ -462,6 +474,30 @@
"const": "deny-lock-screen-orientation",
"markdownDescription": "Denies the lock_screen_orientation command without any pre-configured scope."
},
{
"description": "Enables the requestPermissions command without any pre-configured scope.",
"type": "string",
"const": "allow-requestPermissions",
"markdownDescription": "Enables the requestPermissions command without any pre-configured scope."
},
{
"description": "Denies the requestPermissions command without any pre-configured scope.",
"type": "string",
"const": "deny-requestPermissions",
"markdownDescription": "Denies the requestPermissions command without any pre-configured scope."
},
{
"description": "Enables the request_manage_storage_permission command without any pre-configured scope.",
"type": "string",
"const": "allow-request-manage-storage-permission",
"markdownDescription": "Enables the request_manage_storage_permission command without any pre-configured scope."
},
{
"description": "Denies the request_manage_storage_permission command without any pre-configured scope.",
"type": "string",
"const": "deny-request-manage-storage-permission",
"markdownDescription": "Denies the request_manage_storage_permission command without any pre-configured scope."
},
{
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
@@ -487,10 +523,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-request-manage-storage-permission`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-request-manage-storage-permission`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}
@@ -126,3 +126,10 @@ pub(crate) async fn get_safe_area_insets<R: Runtime>(
) -> Result<GetSafeAreaInsetsResponse> {
app.native_bridge().get_safe_area_insets()
}
#[command]
pub(crate) async fn request_manage_storage_permission<R: Runtime>(
app: AppHandle<R>,
) -> Result<RequestManageStoragePermissionResponse> {
app.native_bridge().request_manage_storage_permission()
}
@@ -106,4 +106,10 @@ impl<R: Runtime> NativeBridge<R> {
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn request_manage_storage_permission(
&self,
) -> crate::Result<RequestManageStoragePermissionResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -53,6 +53,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::iap_restore_purchases,
commands::get_system_color_scheme,
commands::get_safe_area_insets,
commands::request_manage_storage_permission,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -169,3 +169,13 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn request_manage_storage_permission(
&self,
) -> crate::Result<RequestManageStoragePermissionResponse> {
self.0
.run_mobile_plugin("request_manage_storage_permission", ())
.map_err(Into::into)
}
}
@@ -167,3 +167,9 @@ pub struct GetSafeAreaInsetsResponse {
pub left: f64,
pub right: f64,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestManageStoragePermissionResponse {
pub manage_storage: String, // "granted", "denied", or "prompt"
}
@@ -33,9 +33,11 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.media3:media3-common:1.2.0")
implementation("androidx.media3:media3-session:1.2.0")
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
@@ -1,5 +1,6 @@
package com.readest.native_tts
import android.Manifest
import android.os.Bundle
import android.app.Activity
import android.content.Context
@@ -8,8 +9,18 @@ import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.speech.tts.Voice
import android.util.Log
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.ForwardingPlayer
import androidx.media3.session.MediaSession
import androidx.media3.exoplayer.ExoPlayer
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.Permission
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
@@ -25,6 +36,16 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.*
import java.net.URL
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
data class TTSVoiceData(
val id: String,
@@ -60,12 +81,117 @@ class SetVoiceArgs(
val voice: String? = null
)
@TauriPlugin
@InvokeArg
class UpdateMediaSessionMetadataArgs {
var title: String? = null
var artist: String? = null
var album: String? = null
var artwork: String? = null
}
@InvokeArg
class UpdateMediaSessionStateArgs {
var playing: Boolean? = null
var position: Double? = null
var duration: Double? = null
}
@InvokeArg
class SetMediaSessionActiveArgs {
var active: Boolean? = null
var keepAppInForeground: Boolean? = null
var notificationTitle: String? = null
var notificationText: String? = null
var foregroundServiceTitle: String? = null
var foregroundServiceText: String? = null
}
class MediaForegroundService : Service() {
companion object {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "tts_media_playback_channel"
const val ACTION_START = "START_FOREGROUND_SERVICE"
const val ACTION_STOP = "STOP_FOREGROUND_SERVICE"
var pluginInstance: NativeTTSPlugin? = null
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
startForegroundService()
}
ACTION_STOP -> {
stopForegroundService()
}
}
return START_STICKY // Restart if killed
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
NativeTTSPlugin.NOTIFICATION_TITLE,
NotificationManager.IMPORTANCE_MIN
).apply {
description = NativeTTSPlugin.NOTIFICATION_TEXT
setShowBadge(false)
setSound(null, null)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
private fun startForegroundService() {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(NativeTTSPlugin.FOREGROUND_SERVICE_TITLE)
.setContentText(NativeTTSPlugin.FOREGROUND_SERVICE_TEXT)
.setSmallIcon(android.R.drawable.ic_media_play)
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
startForeground(NOTIFICATION_ID, notification)
Log.d("MediaForegroundService", "Foreground service started")
}
private fun stopForegroundService() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.d("MediaForegroundService", "Foreground service stopped")
}
override fun onDestroy() {
super.onDestroy()
Log.d("MediaForegroundService", "Service destroyed")
}
}
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "postNotification")
]
)
class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val TAG = "NativeTTSPlugin"
private const val CHANNEL_NAME = "tts_events"
var NOTIFICATION_TITLE = "Read Aloud"
var NOTIFICATION_TEXT = "Ready to read aloud"
var FOREGROUND_SERVICE_TITLE = "Read Aloud"
var FOREGROUND_SERVICE_TEXT = "Ready to read aloud"
}
private var textToSpeech: TextToSpeech? = null
@@ -78,7 +204,16 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
private val eventChannels = ConcurrentHashMap<String, Channel<TTSMessageEvent>>()
private val speakingJobs = ConcurrentHashMap<String, Job>()
private var player: Player? = null
private var mediaSession: MediaSession? = null
private var isMediaSessionActive = false
private var isForegroundServiceRunning = false
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
init {
MediaForegroundService.pluginInstance = this
}
@Command
fun init(invoke: Invoke) {
@@ -373,13 +508,250 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
invoke.reject("Exception getting voices: ${e.message}")
}
}
@UnstableApi
private class WebViewPlayer(player: Player) : ForwardingPlayer(player) {
var eventTrigger: ((String, JSObject) -> Unit)? = null
override fun play() {
eventTrigger?.invoke("media-session-play", JSObject())
if (!this.playWhenReady) {
this.playWhenReady = true
} else {
this.playWhenReady = false
}
}
override fun pause() {
eventTrigger?.invoke("media-session-pause", JSObject())
if (!this.playWhenReady) {
this.playWhenReady = true
} else {
this.playWhenReady = false
}
}
override fun seekToNext() {
eventTrigger?.invoke("media-session-next", JSObject())
}
override fun seekToPrevious() {
eventTrigger?.invoke("media-session-previous", JSObject())
}
}
private fun initializeMediaSession() {
var basePlayer = ExoPlayer.Builder(activity).build()
val webViewPlayer = WebViewPlayer(basePlayer)
webViewPlayer.eventTrigger = { event, data -> trigger(event, data) }
player = webViewPlayer
mediaSession = MediaSession.Builder(activity, player!!)
.setCallback(object : MediaSession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val builder = MediaSession.ConnectionResult.AcceptedResultBuilder(session)
builder.setAvailablePlayerCommands(
Player.Commands.Builder()
.add(Player.COMMAND_PLAY_PAUSE)
.add(Player.COMMAND_SEEK_TO_NEXT)
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
.build()
)
return builder.build()
}
})
.build()
}
private fun deinitializeMediaSession() {
mediaSession?.release()
mediaSession = null
player?.release()
player = null
}
private fun startForegroundService() {
try {
val intent = Intent(activity, MediaForegroundService::class.java).apply {
action = MediaForegroundService.ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent)
} else {
activity.startService(intent)
}
Log.d(TAG, "Foreground service started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground service", e)
}
}
private fun stopForegroundService() {
try {
val intent = Intent(activity, MediaForegroundService::class.java).apply {
action = MediaForegroundService.ACTION_STOP
}
activity.stopService(intent)
Log.d(TAG, "Foreground service stopped")
} catch (e: Exception) {
Log.e(TAG, "Failed to stop foreground service", e)
}
}
private suspend fun loadArtworkFromUrl(urlString: String): Bitmap? {
return withContext(Dispatchers.IO) {
try {
when {
urlString.startsWith("data:image/") -> {
val base64Data = urlString.substringAfter("base64,")
val decodedBytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
}
urlString.startsWith("http") -> {
val url = URL(urlString)
val input: java.io.InputStream = url.openStream()
BitmapFactory.decodeStream(input)
}
else -> {
val assetPath = urlString.removePrefix("/")
val inputStream = activity.assets.open(assetPath)
BitmapFactory.decodeStream(inputStream)
}
}
} catch (e: Exception) {
null
}
}
}
private var currentArtworkByteArray: ByteArray? = null
@Command
fun update_media_session_metadata(invoke: Invoke) {
val args = invoke.parseArgs(UpdateMediaSessionMetadataArgs::class.java)
val title = args.title ?: ""
val artist = args.artist ?: ""
val album = args.album ?: ""
val artworkUrl = args.artwork ?: ""
coroutineScope.launch {
try {
val metadataBuilder = MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.apply {
if (artworkUrl.isNotEmpty()) {
loadArtworkFromUrl(artworkUrl)?.let { bitmap ->
java.io.ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
currentArtworkByteArray = stream.toByteArray()
}
}
}
currentArtworkByteArray?.let {
setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
}
}
val mediaItem: MediaItem = MediaItem.Builder()
.setMediaId("silent_media")
.setUri("data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQQAAAAAAA==")
.setMediaMetadata(metadataBuilder.build())
.build()
player?.setMediaItems(listOf(mediaItem, mediaItem))
player?.prepare()
player?.playWhenReady = true
player?.setRepeatMode(Player.REPEAT_MODE_ALL)
invoke.resolve()
} catch (e: Exception) {
Log.e("NativeBridgePlugin", "Failed to update metadata: ${e.message}")
invoke.reject("Failed to update metadata: ${e.message}")
}
}
}
@Command
fun update_media_session_state(invoke: Invoke) {
var args = invoke.parseArgs(UpdateMediaSessionStateArgs::class.java)
val isPlaying = args.playing ?: false
val position = args.position ?: 0.0
try {
player?.let { player ->
if (position > 0) {
player.seekTo((position * 1000).toLong())
}
player.playWhenReady = isPlaying
}
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Failed to update playback state: ${e.message}")
}
}
@Command
fun set_media_session_active(invoke: Invoke) {
var args = invoke.parseArgs(SetMediaSessionActiveArgs::class.java)
val active = args.active ?: true
val keepAppInForeground = args.keepAppInForeground ?: false
args.notificationTitle?.let { NOTIFICATION_TITLE = it }
args.notificationText?.let { NOTIFICATION_TEXT = it }
args.foregroundServiceTitle?.let { FOREGROUND_SERVICE_TITLE = it }
args.foregroundServiceText?.let { FOREGROUND_SERVICE_TEXT = it }
try {
if (active && !isMediaSessionActive) {
initializeMediaSession()
if (keepAppInForeground) {
startForegroundService()
isForegroundServiceRunning = true
}
isMediaSessionActive = true
Log.d(TAG, "Media session activated with foreground service")
} else if (!active && isMediaSessionActive) {
deinitializeMediaSession()
if (isForegroundServiceRunning) {
stopForegroundService()
isForegroundServiceRunning = false
}
isMediaSessionActive = false
Log.d(TAG, "Media session deactivated and foreground service stopped")
}
invoke.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to set media session active state: ${e.message}")
invoke.reject("Failed to set media session active state: ${e.message}")
}
}
fun destroy() {
coroutineScope.cancel()
textToSpeech?.shutdown()
eventChannels.values.forEach { it.close() }
eventChannels.clear()
speakingJobs.values.forEach { it.cancel() }
speakingJobs.clear()
try {
if (isMediaSessionActive) {
if (isForegroundServiceRunning) {
stopForegroundService()
isForegroundServiceRunning = false
}
isMediaSessionActive = false
}
deinitializeMediaSession()
coroutineScope.cancel()
textToSpeech?.shutdown()
eventChannels.values.forEach { it.close() }
eventChannels.clear()
speakingJobs.values.forEach { it.cancel() }
speakingJobs.clear()
MediaForegroundService.pluginInstance = null
Log.d(TAG, "Plugin destroyed successfully")
} catch (e: Exception) {
Log.e(TAG, "Error during plugin destruction", e)
}
}
}
@@ -8,6 +8,11 @@ const COMMANDS: &[&str] = &[
"set_pitch",
"set_voice",
"get_all_voices",
"set_media_session_active",
"update_media_session_state",
"update_media_session_metadata",
"checkPermissions",
"requestPermissions",
"registerListener",
"remove_listener",
];
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-checkPermissions"
description = "Enables the checkPermissions command without any pre-configured scope."
commands.allow = ["checkPermissions"]
[[permission]]
identifier = "deny-checkPermissions"
description = "Denies the checkPermissions command without any pre-configured scope."
commands.deny = ["checkPermissions"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-requestPermissions"
description = "Enables the requestPermissions command without any pre-configured scope."
commands.allow = ["requestPermissions"]
[[permission]]
identifier = "deny-requestPermissions"
description = "Denies the requestPermissions command without any pre-configured scope."
commands.deny = ["requestPermissions"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-media-session-active"
description = "Enables the set_media_session_active command without any pre-configured scope."
commands.allow = ["set_media_session_active"]
[[permission]]
identifier = "deny-set-media-session-active"
description = "Denies the set_media_session_active command without any pre-configured scope."
commands.deny = ["set_media_session_active"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-update-media-session-metadata"
description = "Enables the update_media_session_metadata command without any pre-configured scope."
commands.allow = ["update_media_session_metadata"]
[[permission]]
identifier = "deny-update-media-session-metadata"
description = "Denies the update_media_session_metadata command without any pre-configured scope."
commands.deny = ["update_media_session_metadata"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-update-media-session-state"
description = "Enables the update_media_session_state command without any pre-configured scope."
commands.allow = ["update_media_session_state"]
[[permission]]
identifier = "deny-update-media-session-state"
description = "Denies the update_media_session_state command without any pre-configured scope."
commands.deny = ["update_media_session_state"]
@@ -13,6 +13,11 @@ Default permissions for the plugin
- `allow-set-pitch`
- `allow-set-voice`
- `allow-get-all-voices`
- `allow-set-media-session-active`
- `allow-update-media-session-state`
- `allow-update-media-session-metadata`
- `allow-checkPermissions`
- `allow-requestPermissions`
- `allow-registerListener`
- `allow-remove-listener`
@@ -25,6 +30,32 @@ Default permissions for the plugin
</tr>
<tr>
<td>
`native-tts:allow-checkPermissions`
</td>
<td>
Enables the checkPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-checkPermissions`
</td>
<td>
Denies the checkPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
@@ -158,6 +189,32 @@ Denies the remove_listener command without any pre-configured scope.
<tr>
<td>
`native-tts:allow-requestPermissions`
</td>
<td>
Enables the requestPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-requestPermissions`
</td>
<td>
Denies the requestPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-resume`
</td>
@@ -184,6 +241,32 @@ Denies the resume command without any pre-configured scope.
<tr>
<td>
`native-tts:allow-set-media-session-active`
</td>
<td>
Enables the set_media_session_active command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-set-media-session-active`
</td>
<td>
Denies the set_media_session_active command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-set-pitch`
</td>
@@ -308,6 +391,58 @@ Enables the stop command without any pre-configured scope.
Denies the stop command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-update-media-session-metadata`
</td>
<td>
Enables the update_media_session_metadata command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-update-media-session-metadata`
</td>
<td>
Denies the update_media_session_metadata command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-update-media-session-state`
</td>
<td>
Enables the update_media_session_state command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-update-media-session-state`
</td>
<td>
Denies the update_media_session_state command without any pre-configured scope.
</td>
</tr>
</table>
@@ -10,6 +10,11 @@ permissions = [
"allow-set-pitch",
"allow-set-voice",
"allow-get-all-voices",
"allow-set-media-session-active",
"allow-update-media-session-state",
"allow-update-media-session-metadata",
"allow-checkPermissions",
"allow-requestPermissions",
"allow-registerListener",
"allow-remove-listener",
]
@@ -294,6 +294,18 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the checkPermissions command without any pre-configured scope.",
"type": "string",
"const": "allow-checkPermissions",
"markdownDescription": "Enables the checkPermissions command without any pre-configured scope."
},
{
"description": "Denies the checkPermissions command without any pre-configured scope.",
"type": "string",
"const": "deny-checkPermissions",
"markdownDescription": "Denies the checkPermissions command without any pre-configured scope."
},
{
"description": "Enables the get_all_voices command without any pre-configured scope.",
"type": "string",
@@ -354,6 +366,18 @@
"const": "deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the requestPermissions command without any pre-configured scope.",
"type": "string",
"const": "allow-requestPermissions",
"markdownDescription": "Enables the requestPermissions command without any pre-configured scope."
},
{
"description": "Denies the requestPermissions command without any pre-configured scope.",
"type": "string",
"const": "deny-requestPermissions",
"markdownDescription": "Denies the requestPermissions command without any pre-configured scope."
},
{
"description": "Enables the resume command without any pre-configured scope.",
"type": "string",
@@ -366,6 +390,18 @@
"const": "deny-resume",
"markdownDescription": "Denies the resume command without any pre-configured scope."
},
{
"description": "Enables the set_media_session_active command without any pre-configured scope.",
"type": "string",
"const": "allow-set-media-session-active",
"markdownDescription": "Enables the set_media_session_active command without any pre-configured scope."
},
{
"description": "Denies the set_media_session_active command without any pre-configured scope.",
"type": "string",
"const": "deny-set-media-session-active",
"markdownDescription": "Denies the set_media_session_active command without any pre-configured scope."
},
{
"description": "Enables the set_pitch command without any pre-configured scope.",
"type": "string",
@@ -427,10 +463,34 @@
"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`",
"description": "Enables the update_media_session_metadata command without any pre-configured scope.",
"type": "string",
"const": "allow-update-media-session-metadata",
"markdownDescription": "Enables the update_media_session_metadata command without any pre-configured scope."
},
{
"description": "Denies the update_media_session_metadata command without any pre-configured scope.",
"type": "string",
"const": "deny-update-media-session-metadata",
"markdownDescription": "Denies the update_media_session_metadata command without any pre-configured scope."
},
{
"description": "Enables the update_media_session_state command without any pre-configured scope.",
"type": "string",
"const": "allow-update-media-session-state",
"markdownDescription": "Enables the update_media_session_state command without any pre-configured scope."
},
{
"description": "Denies the update_media_session_state command without any pre-configured scope.",
"type": "string",
"const": "deny-update-media-session-state",
"markdownDescription": "Denies the update_media_session_state 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-set-media-session-active`\n- `allow-update-media-session-state`\n- `allow-update-media-session-metadata`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\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`"
"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-set-media-session-active`\n- `allow-update-media-session-state`\n- `allow-update-media-session-metadata`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-registerListener`\n- `allow-remove-listener`"
}
]
}
@@ -51,3 +51,27 @@ pub(crate) async fn set_voice<R: Runtime>(app: AppHandle<R>, payload: SetVoiceAr
pub(crate) async fn get_all_voices<R: Runtime>(app: AppHandle<R>) -> Result<GetVoicesResponse> {
app.native_tts().get_all_voices()
}
#[command]
pub(crate) async fn set_media_session_active<R: Runtime>(
app: AppHandle<R>,
payload: SetMediaSessionActiveRequest,
) -> Result<()> {
app.native_tts().set_media_session_active(payload)
}
#[command]
pub(crate) async fn update_media_session_state<R: Runtime>(
app: AppHandle<R>,
payload: UpdateMediaSessionStateRequest,
) -> Result<()> {
app.native_tts().update_media_session_state(payload)
}
#[command]
pub(crate) async fn update_media_session_metadata<R: Runtime>(
app: AppHandle<R>,
payload: UpdateMediaSessionMetadataRequest,
) -> Result<()> {
app.native_tts().update_media_session_metadata(payload)
}
@@ -41,4 +41,22 @@ impl<R: Runtime> NativeTts<R> {
pub fn get_all_voices(&self) -> crate::Result<GetVoicesResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_media_session_active(
&self,
_payload: SetMediaSessionActiveRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn update_media_session_state(
&self,
_payload: UpdateMediaSessionStateRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn update_media_session_metadata(
&self,
_payload: UpdateMediaSessionMetadataRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -45,6 +45,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::set_pitch,
commands::set_voice,
commands::get_all_voices,
commands::set_media_session_active,
commands::update_media_session_state,
commands::update_media_session_metadata,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -87,3 +87,36 @@ impl<R: Runtime> NativeTts<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn set_media_session_active(
&self,
payload: SetMediaSessionActiveRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("set_media_session_active", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn update_media_session_state(
&self,
payload: UpdateMediaSessionStateRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("update_media_session_state", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn update_media_session_metadata(
&self,
payload: UpdateMediaSessionMetadataRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("update_media_session_metadata", payload)
.map_err(Into::into)
}
}
@@ -61,3 +61,31 @@ pub struct SetVoiceArgs {
pub struct GetVoicesResponse {
pub voices: Vec<TTSVoice>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetMediaSessionActiveRequest {
pub active: bool,
pub keep_app_in_foreground: bool,
pub notification_title: Option<String>,
pub notification_text: Option<String>,
pub foreground_service_title: Option<String>,
pub foreground_service_text: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMediaSessionStateRequest {
pub playing: bool,
pub position: Option<f64>,
pub duration: Option<f64>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMediaSessionMetadataRequest {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub artwork: Option<String>,
}
+34 -18
View File
@@ -42,6 +42,22 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) {
let fs_scope = app.fs_scope();
let asset_protocol_scope = app.asset_protocol_scope();
if let Err(e) = fs_scope.allow_directory(dir, true) {
eprintln!("Failed to allow directory in fs_scope: {e}");
} else {
println!("Allowed directory in fs_scope: {dir:?}");
}
if let Err(e) = asset_protocol_scope.allow_directory(dir, true) {
eprintln!("Failed to allow directory in asset_protocol_scope: {e}");
} else {
println!("Allowed directory in asset_protocol_scope: {dir:?}");
}
}
#[cfg(desktop)]
fn get_files_from_argv(argv: Vec<String>) -> Vec<PathBuf> {
let mut files = Vec::new();
@@ -87,15 +103,6 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn set_rounded_window(app: &AppHandle, rounded: bool) {
let window = app.get_webview_window("main").unwrap();
let script = format!("window.IS_ROUNDED = {rounded};");
if let Err(e) = window.eval(&script) {
eprintln!("Failed to set IS_ROUNDED variable: {e}");
}
}
#[command]
async fn start_server(window: Window) -> Result<u16, String> {
start(move |url| {
@@ -111,6 +118,15 @@ fn get_environment_variable(name: &str) -> String {
std::env::var(String::from(name)).unwrap_or(String::from(""))
}
#[tauri::command]
fn get_executable_dir() -> String {
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(|p| p.to_path_buf()))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
}
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
@@ -128,6 +144,7 @@ pub fn run() {
download_file,
upload_file,
get_environment_variable,
get_executable_dir,
#[cfg(target_os = "macos")]
macos::safari_auth::auth_with_safari,
#[cfg(target_os = "macos")]
@@ -135,14 +152,15 @@ pub fn run() {
#[cfg(target_os = "macos")]
macos::traffic_light::set_traffic_lights,
])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_persisted_scope::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_http::init())
.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());
.plugin(tauri_plugin_native_tts::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
@@ -193,6 +211,11 @@ pub fn run() {
}
}
#[cfg(desktop)]
{
allow_dir_in_scopes(app.handle(), &PathBuf::from(get_executable_dir()));
}
#[cfg(desktop)]
{
app.handle().plugin(tauri_plugin_cli::init())?;
@@ -204,13 +227,6 @@ pub fn run() {
.eval("window.__READEST_CLI_ACCESS = true;")
.expect("Failed to set cli access config");
set_rounded_window(&app_handle, true);
#[cfg(target_os = "windows")]
if tauri_plugin_os::version()
<= tauri_plugin_os::Version::from_string("10.0.19045")
{
set_rounded_window(&app_handle, false);
}
#[cfg(target_os = "linux")]
{
let is_appimage = std::env::var("APPIMAGE").is_ok()
@@ -30,6 +30,7 @@
"$APPDATA/**/*",
"$APPCACHE/**/*",
"$TEMP/**/*",
"**/Readest/**/*",
"/private/var/mobile/Containers/Data/Application/**/*"
],
"deny": []
+1 -2
View File
@@ -344,8 +344,7 @@ export default function AuthPage() {
return isTauriAppPlatform() ? (
<div
className={clsx(
'bg-base-100 inset-0 flex select-none flex-col items-center overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
'bg-base-100 inset-0 flex h-[100vh] select-none flex-col items-center overflow-hidden',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
+2 -2
View File
@@ -4,7 +4,7 @@ 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';
import { parseWebViewInfo } from '@/utils/ua';
import { handleGlobalError } from '@/utils/error';
interface ErrorPageProps {
@@ -18,7 +18,7 @@ export default function Error({ error, reset }: ErrorPageProps) {
const [browserInfo, setBrowserInfo] = useState('');
useEffect(() => {
setBrowserInfo(parseWebViewVersion(appService));
setBrowserInfo(parseWebViewInfo(appService));
posthog.captureException(error);
handleGlobalError(error);
}, [appService, error]);
@@ -353,16 +353,21 @@ const Bookshelf: React.FC<BookshelfProps> = ({
/>
))}
{viewMode === 'grid' && !navBooksGroup && allBookshelfItems.length > 0 && (
<button
aria-label={_('Import Books')}
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
'mx-0 my-4 aspect-[28/41] sm:mx-4',
)}
onClick={handleImportBooks}
>
<PiPlus className='size-10' color='gray' />
</button>
<div className={clsx('mx-0 my-4 sm:mx-4')}>
<button
aria-label={_('Import Books')}
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50',
'flex items-center justify-center',
'aspect-[28/41] w-full',
)}
onClick={handleImportBooks}
>
<div className='flex items-center justify-center'>
<PiPlus className='size-10' color='gray' />
</div>
</button>
</div>
)}
</div>
{loading && (
@@ -290,7 +290,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
groupContextMenuHandler(item as BooksGroup);
}
}, 100),
[itemSelected],
[itemSelected, settings.localBooksDir],
);
const { pressing, handlers } = useLongPress(
@@ -342,7 +342,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
onKeyDown={handleKeyDown}
{...handlers}
>
<div className='flex-grow'>
<div className='flex h-full flex-col justify-end'>
{'format' in item ? (
<BookItem
mode={mode}
@@ -96,12 +96,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
};
return (
<div
className={clsx(
'group-item flex h-full flex-col justify-end',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
>
<div className={clsx('group-item', appService?.hasContextMenu ? 'cursor-pointer' : '')}>
<div
className={clsx(
'relative flex overflow-hidden',
@@ -20,7 +20,6 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
return (
<Menu
label={_('Import Books')}
className={clsx(
'dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow',
appService?.isMobile ? 'no-triangle' : 'dropdown-center',
@@ -0,0 +1,482 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import {
RiFolderOpenLine,
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoader2Line,
} from 'react-icons/ri';
import { documentDir, join } from '@tauri-apps/api/path';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { relaunch } from '@tauri-apps/plugin-process';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { DATA_SUBDIR } from '@/services/constants';
import { FileItem } from '@/types/system';
import { getDirPath } from '@/utils/path';
import { formatBytes } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import Dialog from '@/components/Dialog';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/MenuItem';
export const setMigrateDataDirDialogVisible = (visible: boolean) => {
const dialog = document.getElementById('migrate_data_dir_window');
if (dialog) {
const event = new CustomEvent('setDialogVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
type MigrationStatus = 'idle' | 'selecting' | 'migrating' | 'completed' | 'error';
interface MigrationProgress {
current: number;
total: number;
currentFile?: string;
}
interface Permissions {
manageStorage: PermissionState;
}
export const MigrateDataWindow = () => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [isOpen, setIsOpen] = useState(false);
const [currentDataDir, setCurrentDataDir] = useState('');
const [newDataDir, setNewDataDir] = useState('');
const [migrationStatus, setMigrationStatus] = useState<MigrationStatus>('idle');
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress>({
current: 0,
total: 0,
});
const [errorMessage, setErrorMessage] = useState('');
const [filesToMigrate, setFilesToMigrate] = useState<FileItem[]>([]);
const [currentDirFileCount, setCurrentDirFileCount] = useState('');
const [currentDirFileSize, setCurrentDirFileSize] = useState(0);
const [androidNewDirs, setAndroidNewDirs] = useState<{ path: string; label: string }[]>([]);
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
loadCurrentDataDir();
loadAndroidDirs();
}
};
const el = document.getElementById('migrate_data_dir_window');
if (el) {
el.addEventListener('setDialogVisibility', handleCustomEvent as EventListener);
}
return () => {
if (el) {
el.removeEventListener('setDialogVisibility', handleCustomEvent as EventListener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadCurrentDataDir = async () => {
try {
if (!appService) return;
const dataDir = await appService.resolveFilePath('', 'Data');
setCurrentDataDir(dataDir);
const files = await appService.readDirectory(dataDir, 'None');
setFilesToMigrate(files);
setCurrentDirFileCount(files.length.toLocaleString());
setCurrentDirFileSize(files.reduce((acc, file) => acc + file.size, 0));
} catch (error) {
console.error('Error loading current data directory:', error);
}
};
const loadAndroidDirs = async () => {
const sdcardDirs = [
{ path: '/storage/emulated/0', label: '/sdcard' },
{ path: '/storage/emulated/0/Books', label: '/sdcard/Books' },
{ path: '/storage/emulated/0/Documents', label: '/sdcard/Documents' },
{ path: '/storage/emulated/0/Download', label: '/sdcard/Download' },
];
try {
if (appService?.isAndroidApp) {
const localDocumentDir = await documentDir();
setAndroidNewDirs([
// For Google Play version we won't request permission to access root of /sdcard
...(appService?.distChannel === 'playstore' ? [] : sdcardDirs),
{ path: localDocumentDir, label: '/sdcard/APPDATA/Documents' },
]);
}
} catch (error) {
console.error('Error loading app local data directory:', error);
}
};
const handleSelectNewDir = async () => {
setMigrationStatus('selecting');
setErrorMessage('');
try {
const selectedDir = await appService?.selectDirectory?.('write');
if (selectedDir) {
const newDataDir = await join(selectedDir, DATA_SUBDIR);
await appService?.createDir(newDataDir, 'None', true);
setNewDataDir(newDataDir);
setMigrationStatus('idle');
} else {
setMigrationStatus('idle');
}
} catch (error) {
console.error('Error selecting directory:', error);
setErrorMessage(_('Failed to select directory'));
setMigrationStatus('error');
}
};
const handleSelectedNewDir = async (dir: string) => {
setErrorMessage('');
if (!dir.includes('Android/data')) {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
if (permission.manageStorage !== 'granted') return;
}
try {
const newDataDir = await join(dir, DATA_SUBDIR);
await appService?.createDir(newDataDir, 'None', true);
setNewDataDir(newDataDir);
setMigrationStatus('idle');
} catch (error) {
console.error('Error selecting directory:', error);
setErrorMessage(_('Failed to select directory'));
setMigrationStatus('error');
}
};
const handleStartMigration = async () => {
if (!appService || !currentDataDir || !newDataDir || !filesToMigrate.length) return;
setMigrationStatus('migrating');
setErrorMessage('');
setMigrationProgress({ current: 0, total: 0 });
try {
if (newDataDir === currentDataDir) {
throw new Error(_('The new data directory must be different from the current one.'));
}
// Copy all files to new location
for (let i = 0; i < filesToMigrate.length; i++) {
const file = filesToMigrate[i]!;
setMigrationProgress({
current: i + 1,
total: filesToMigrate.length,
currentFile: file.path,
});
const srcPath = await join(currentDataDir, file.path);
const destPath = await join(newDataDir, file.path);
await appService.copyFile(srcPath, destPath, 'None');
}
// Verify all files copied
const filesMigrated = await appService.readDirectory(newDataDir, 'None');
for (const file of filesToMigrate) {
if (!filesMigrated.find((f) => f.path === file.path && f.size === file.size)) {
throw new Error(`File ${file.path} failed to copy.`);
}
}
// Delete old data directory
await appService.deleteDir(currentDataDir, 'None', true);
// Update settings for new data directory
const customRootDir = getDirPath(newDataDir);
await appService.setCustomRootDir(customRootDir);
settings.customRootDir = customRootDir;
settings.localBooksDir = await appService.resolveFilePath('', 'Books');
setSettings({ ...settings });
await saveSettings(envConfig, settings);
// Finalize migration
setMigrationStatus('completed');
setCurrentDataDir(newDataDir);
setFilesToMigrate([]);
setCurrentDirFileCount('');
setCurrentDirFileSize(0);
loadCurrentDataDir();
} catch (error) {
console.error('Error migrating data:', error);
setErrorMessage(_('Migration failed: {{error}}', { error: error || 'Unknown error' }));
setMigrationStatus('error');
}
};
const handleClose = () => {
if (migrationStatus === 'migrating') {
return;
}
setIsOpen(false);
setNewDataDir('');
setMigrationStatus('idle');
setErrorMessage('');
setMigrationProgress({ current: 0, total: 0 });
};
const handleRestartApp = () => {
relaunch();
};
const handleRevealDir = (dataDir: string) => {
if (dataDir && appService?.isDesktopApp) {
revealItemInDir(dataDir);
}
};
const progressPercentage =
migrationProgress.total > 0
? Math.round((migrationProgress.current / migrationProgress.total) * 100)
: 0;
const canStartMigration =
newDataDir && newDataDir !== currentDataDir && migrationStatus === 'idle';
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
return (
<Dialog
id='migrate_data_dir_window'
isOpen={isOpen}
title={_('Change Data Location')}
onClose={handleClose}
boxClassName='sm:!w-[520px] sm:!max-w-screen-sm sm:h-auto'
>
{isOpen && (
<div className='migrate-data-dir-content flex flex-col gap-6 px-6 py-4'>
{/* Current Data Directory */}
<div className='space-y-2'>
<h3 className='text-base-content text-sm font-semibold'>
{_('Current Data Location')}
</h3>
<button
title={_(fileRevealLabel)}
className='bg-base-200 flex w-full items-center gap-2 rounded-lg p-3'
onClick={() => handleRevealDir(currentDataDir)}
>
<RiFolderOpenLine className='text-base-content/70 h-4 w-4 flex-shrink-0' />
<span className='text-base-content/80 break-all text-start font-mono text-sm'>
{currentDataDir || _('Loading...')}
</span>
</button>
{currentDirFileCount ? (
<div className='flex space-x-4'>
<p className='text-base-content/60 text-xs'>
{_('File count: {{size}}', { size: currentDirFileCount })}
</p>
<p className='text-base-content/60 text-xs'>
{_('Total size: {{size}}', { size: formatBytes(currentDirFileSize) })}
</p>
</div>
) : (
<p className='text-base-content/60 text-xs'>{_('Calculating file info...')}</p>
)}
</div>
{/* New Data Directory Selection */}
<div className='space-y-3'>
<h3 className='text-base-content text-sm font-semibold'>{_('New Data Location')}</h3>
{newDataDir && (
<button
title={_(fileRevealLabel)}
className='bg-primary/10 border-primary/20 flex w-full items-center gap-2 rounded-lg border p-3'
onClick={() => handleRevealDir(newDataDir)}
>
<RiFolderOpenLine className='text-primary h-4 w-4 flex-shrink-0' />
<span className='text-primary break-all text-start font-mono text-sm'>
{newDataDir}
</span>
</button>
)}
{appService?.isAndroidApp ? (
<Dropdown
label={_('Choose New Folder')}
className='dropdown-bottom flex w-full justify-center'
buttonClassName='btn btn-ghost btn-outline w-full'
toggleButton={
<div>{newDataDir ? _('Choose Different Folder') : _('Choose New Folder')}</div>
}
>
<div
className={clsx(
'folder-menu dropdown-content dropdown-center no-triangle',
'border-base-300 !bg-base-200 z-20 mt-1 max-w-[90vw] shadow-2xl',
)}
>
{androidNewDirs.map((dir) => (
<MenuItem
key={dir.path}
toggled={newDataDir.split(`/${DATA_SUBDIR}`)[0] === dir.path}
transient
label={dir.label}
onClick={() => handleSelectedNewDir(dir.path)}
/>
))}
</div>
</Dropdown>
) : (
<button
className='btn btn-outline btn-sm w-full'
onClick={handleSelectNewDir}
disabled={migrationStatus === 'migrating' || migrationStatus === 'selecting'}
>
{migrationStatus === 'selecting' && (
<RiLoader2Line className='h-4 w-4 animate-spin' />
)}
{newDataDir ? _('Choose Different Folder') : _('Choose New Folder')}
</button>
)}
</div>
{/* Migration Progress */}
{migrationStatus === 'migrating' && (
<div className='space-y-3'>
<div className='flex items-center gap-2'>
<RiLoader2Line className='text-primary h-4 w-4 animate-spin' />
<span className='text-base-content text-sm font-medium'>
{_('Migrating data...')}
</span>
<span className='text-base-content/70 text-sm'>{progressPercentage}%</span>
</div>
<div className='bg-base-200 h-2 w-full rounded-full'>
<div
className='bg-primary h-2 rounded-full transition-all duration-300'
style={{ width: `${progressPercentage}%` }}
/>
</div>
{migrationProgress.currentFile && (
<p
className='text-base-content/60 overflow-hidden font-mono text-xs'
style={{
direction: 'rtl',
textAlign: 'left',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{_('Copying: {{file}}', { file: migrationProgress.currentFile })}
</p>
)}
<p className='text-base-content/60 text-xs'>
{_('{{current}} of {{total}} files', {
current: migrationProgress.current.toLocaleString(),
total: migrationProgress.total.toLocaleString(),
})}
</p>
</div>
)}
{/* Success State */}
{migrationStatus === 'completed' && (
<div className='space-y-3'>
<div className='text-success flex items-center gap-2'>
<RiCheckboxCircleFill className='h-5 w-5' />
<span className='font-medium'>{_('Migration completed successfully!')}</span>
</div>
<div className='bg-success/10 border-success/20 rounded-lg border p-3'>
<p className='text-success/80 text-sm'>
{_(
'Your data has been moved to the new location. Please restart the application to complete the process.',
)}
</p>
</div>
</div>
)}
{/* Error State */}
{migrationStatus === 'error' && errorMessage && (
<div className='space-y-2'>
<div className='text-error flex items-center gap-2'>
<RiErrorWarningFill className='h-5 w-5' />
<span className='font-medium'>{_('Migration failed')}</span>
</div>
<div className='bg-error/10 border-error/20 rounded-lg border p-3'>
<p className='text-error/80 break-all text-sm'>{errorMessage}</p>
</div>
</div>
)}
{/* Warning */}
{canStartMigration && (
<div className='bg-warning/10 border-warning/20 rounded-lg border p-3'>
<div className='flex items-start gap-2'>
<RiErrorWarningFill className='text-warning mt-0.5 h-4 w-4 flex-shrink-0' />
<div className='space-y-1'>
<p className='text-base-content text-sm font-medium'>{_('Important Notice')}</p>
<p className='text-base-content/80 text-sm'>
{_(
'This will move all your app data to the new location. Make sure the destination has enough free space.',
)}
</p>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className='flex gap-3 pt-2'>
{migrationStatus === 'completed' ? (
<>
<button className='btn btn-outline flex-1' onClick={handleClose}>
{_('Close')}
</button>
<button className='btn btn-primary flex-1' onClick={handleRestartApp}>
{_('Restart App')}
</button>
</>
) : (
<>
<button
className='btn btn-outline flex-1'
onClick={handleClose}
disabled={migrationStatus === 'migrating'}
>
{_('Cancel')}
</button>
<button
className='btn btn-primary flex-1'
onClick={handleStartMigration}
disabled={!canStartMigration || migrationStatus !== 'idle'}
>
{migrationStatus === 'migrating' && (
<RiLoader2Line className='h-4 w-4 animate-spin' />
)}
{_('Start Migration')}
</button>
</>
)}
</div>
</div>
)}
</Dialog>
);
};
@@ -1,12 +1,11 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { PiUserCircle } from 'react-icons/pi';
import { PiUserCircleCheck } from 'react-icons/pi';
import { PiUserCircle, PiUserCircleCheck, PiGear } from 'react-icons/pi';
import { PiSun, PiMoon } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { BiMoon, BiSun } from 'react-icons/bi';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
@@ -19,6 +18,8 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
import UserAvatar from '@/components/UserAvatar';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -28,6 +29,10 @@ interface SettingsMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
interface Permissions {
postNotification: PermissionState;
}
const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
@@ -36,6 +41,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const { userPlan, quotas } = useQuotaStats(true);
const { themeMode, setThemeMode } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
@@ -46,6 +52,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
settings.autoImportBooksOnOpen,
);
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
const [alwaysInForeground, setAlwaysInForeground] = useState(settings.alwaysInForeground);
const iconSize = useResponsiveSize(16);
const showAboutReadest = () => {
@@ -162,6 +169,35 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const handleSetRootDir = () => {
setMigrateDataDirDialogVisible(true);
setIsDropdownOpen?.(false);
};
const openSettingsDialog = () => {
setIsDropdownOpen?.(false);
setFontLayoutSettingsDialogOpen(true);
};
const toggleAlwaysInForeground = async () => {
const requestAlwaysInForeground = !settings.alwaysInForeground;
if (requestAlwaysInForeground) {
let permission = await invoke<Permissions>('plugin:native-tts|checkPermissions');
if (permission.postNotification !== 'granted') {
permission = await invoke<Permissions>('plugin:native-tts|requestPermissions', {
permissions: ['postNotification'],
});
}
if (permission.postNotification !== 'granted') return;
}
settings.alwaysInForeground = requestAlwaysInForeground;
setSettings(settings);
saveSettings(envConfig, settings);
setAlwaysInForeground(settings.alwaysInForeground);
};
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;
@@ -174,7 +210,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
return (
<Menu
label={_('Settings Menu')}
className={clsx(
'settings-menu dropdown-content no-triangle border-base-100',
'z-20 mt-2 max-w-[90vw] shadow-2xl',
@@ -257,12 +292,30 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
toggled={isScreenWakeLock}
onClick={toggleScreenWakeLock}
/>
{appService?.isAndroidApp && (
<MenuItem
label={_(_('Background Read Aloud'))}
toggled={alwaysInForeground}
onClick={toggleAlwaysInForeground}
/>
)}
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<MenuItem
label={themeModeLabel}
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
Icon={themeMode === 'dark' ? PiMoon : themeMode === 'light' ? PiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
<MenuItem label={_('Settings')} Icon={PiGear} onClick={openSettingsDialog} />
{appService?.canCustomizeRootDir && (
<>
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Advanced Settings')}>
<ul className='flex flex-col'>
<MenuItem label={_('Change Data Location')} noIcon onClick={handleSetRootDir} />
</ul>
</MenuItem>
</>
)}
<hr aria-hidden='true' className='border-base-200 my-1' />
{user && userPlan === 'free' && !appService?.isIOSApp && (
<MenuItem label={_('Upgrade to Readest Premium')} onClick={handleUpgrade} />
@@ -92,10 +92,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
};
return (
<Menu
label={_('View Menu')}
className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
<Menu className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'>
{viewOptions.map((option) => (
<MenuItem
key={option.value}
+9 -4
View File
@@ -43,16 +43,18 @@ import {
tauriQuitApp,
} from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { BookMetadata } from '@/libs/document';
import { AboutWindow } from '@/components/AboutWindow';
import { BookDetailModal } from '@/components/metadata';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { MigrateDataWindow } from './components/MigrateDataWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import useShortcuts from '@/hooks/useShortcuts';
import DropIndicator from '@/components/DropIndicator';
import SettingsDialog from '@/components/settings/SettingsDialog';
const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams();
@@ -76,6 +78,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { selectFiles } = useFileSelector(appService, _);
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isFontLayoutSettingsDialogOpen } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -88,6 +91,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}>({});
const [pendingNavigationBookIds, setPendingNavigationBookIds] = useState<string[] | null>(null);
const [isDragging, setIsDragging] = useState(false);
const demoBooks = useDemoBooks();
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
@@ -629,8 +633,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
ref={pageRef}
aria-label='Your Library'
className={clsx(
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
'library-page bg-base-200 text-base-content flex h-[100vh] select-none flex-col overflow-hidden',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
@@ -729,6 +732,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
)}
<AboutWindow />
<UpdaterWindow />
<MigrateDataWindow />
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={''} />}
<Toast />
</div>
);
@@ -10,13 +10,13 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import SettingsDialog from '@/components/settings/SettingsDialog';
import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
import FooterBar from './FooterBar';
import ProgressInfoView from './ProgressInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
@@ -34,7 +34,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { setGridInsets, hoveredBookKey } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const { isFontLayoutSettingsDialogOpen } = useSettingsStore();
const { safeAreaInsets: screenInsets } = useThemeStore();
const aspectRatio = window.innerWidth / window.innerHeight;
@@ -120,7 +120,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isTopLeft={index === 0}
isHoveredAnim={bookKeys.length > 2}
onCloseBook={onCloseBook}
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
gridInsets={gridInsets}
/>
<FoliateViewer
@@ -203,7 +202,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isHoveredAnim={false}
gridInsets={gridInsets}
/>
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} />}
</div>
);
})}
@@ -18,9 +18,9 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { saveViewSettings } from '@/helpers/viewSettings';
import { eventDispatcher } from '@/utils/event';
import { viewPagination } from '../hooks/usePagination';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { PageInfo } from '@/types/book';
import { Insets } from '@/types/misc';
import Button from '@/components/Button';
@@ -26,7 +26,6 @@ interface HeaderBarProps {
isHoveredAnim: boolean;
gridInsets: Insets;
onCloseBook: (bookKey: string) => void;
onSetSettingsDialogOpen: (open: boolean) => void;
}
const HeaderBar: React.FC<HeaderBarProps> = ({
@@ -36,7 +35,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
isHoveredAnim,
gridInsets,
onCloseBook,
onSetSettingsDialogOpen,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -185,7 +183,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
toggleButton={<PiDotsThreeVerticalBold size={iconSize16} />}
onToggle={handleToggleDropdown}
>
<ViewMenu bookKey={bookKey} onSetSettingsDialogOpen={onSetSettingsDialogOpen} />
<ViewMenu bookKey={bookKey} />
</Dropdown>
<WindowButtons
@@ -141,8 +141,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
return libraryLoaded && settings.globalReadSettings ? (
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none overflow-hidden`,
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
'reader-page bg-base-100 text-base-content h-[100vh] select-none overflow-hidden',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
@@ -1,6 +1,5 @@
'use client';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
@@ -185,7 +184,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
setTimeout(() => setLoading(true), 200);
return (
loading && (
<div className={clsx('hero hero-content', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<div className='hero hero-content h-[100vh]'>
<Spinner loading={true} />
</div>
)
@@ -193,7 +192,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
return (
<div className={clsx('reader-content flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<div className='reader-content flex h-[100vh]'>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<Notebook />
@@ -50,6 +50,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
<div
className={clsx(
'sectioninfo absolute flex items-center overflow-hidden',
'text-neutral-content font-sans text-xs font-light',
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
isScrolled && !isVertical && 'bg-base-100',
)}
@@ -75,7 +76,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
<span
aria-hidden='true'
className={clsx(
'text-neutral-content text-center font-sans text-xs font-light',
'text-center',
isVertical ? '' : 'line-clamp-1',
!isVertical && hoveredBookKey == bookKey && 'hidden',
)}
@@ -5,7 +5,7 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { saveViewSettings } from '@/helpers/viewSettings';
import { isTranslationAvailable } from '@/services/translators/utils';
import Button from '@/components/Button';
@@ -16,31 +16,29 @@ import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { navigateToLogin } from '@/utils/nav';
import { eventDispatcher } from '@/utils/event';
import { getMaxInlineSize } from '@/utils/config';
import { saveViewSettings } from '@/helpers/viewSettings';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
interface ViewMenuProps {
bookKey: string;
setIsDropdownOpen?: (open: boolean) => void;
onSetSettingsDialogOpen: (open: boolean) => void;
}
const ViewMenu: React.FC<ViewMenuProps> = ({
bookKey,
setIsDropdownOpen,
onSetSettingsDialogOpen,
}) => {
const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { user } = useAuth();
const { envConfig, appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const { getView, getViewSettings, getViewState, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
@@ -64,7 +62,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const openFontLayoutMenu = () => {
setIsDropdownOpen?.(false);
onSetSettingsDialogOpen(true);
setFontLayoutSettingsDialogOpen(true);
};
const cycleThemeMode = () => {
@@ -146,7 +144,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const lastSyncTime = Math.max(config?.lastSyncedAtConfig || 0, config?.lastSyncedAtNotes || 0);
return (
<div
<Menu
className={clsx(
'view-menu dropdown-content dropdown-right no-triangle z-20 mt-1 border',
'bgcolor-base-200 border-base-200 shadow-2xl',
@@ -156,86 +154,101 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
marginRight: window.innerWidth < 640 ? '-36px' : '0px',
}}
>
<div className={clsx('flex items-center justify-between rounded-md')}>
<button
onClick={zoomOut}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomLevel <= MIN_ZOOM_LEVEL && 'btn-disabled text-gray-400',
)}
>
<MdZoomOut />
</button>
<button
className={clsx(
'hover:bg-base-300 text-base-content h-8 min-h-8 w-[50%] rounded-md p-1 text-center',
)}
onClick={resetZoom}
>
{zoomLevel}%
</button>
<button
onClick={zoomIn}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomLevel >= MAX_ZOOM_LEVEL && 'btn-disabled text-gray-400',
)}
>
<MdZoomIn />
</button>
</div>
{bookData.bookDoc?.rendition?.layout === 'pre-paginated' && (
<>
<div className={clsx('my-2 flex items-center justify-between rounded-md')}>
<div
title={_('Zoom Level')}
className={clsx('flex items-center justify-between rounded-md')}
>
<button
onClick={setSpreadMode.bind(null, 'none')}
title={_('Zoom Out')}
onClick={zoomOut}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'none' && 'bg-base-300/75',
zoomLevel <= MIN_ZOOM_LEVEL && 'btn-disabled text-gray-400',
)}
>
<TbColumns1 />
<MdZoomOut />
</button>
<button
onClick={setSpreadMode.bind(null, 'auto')}
title={_('Reset Zoom')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'auto' && 'bg-base-300/75',
'hover:bg-base-300 text-base-content h-8 min-h-8 w-[50%] rounded-md p-1 text-center',
)}
onClick={resetZoom}
>
<TbColumns2 />
</button>
<div className='bg-base-300 mx-2 h-6 w-[1px]' />
<button
onClick={setZoomMode.bind(null, 'fit-page')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-page' && 'bg-base-300/75',
)}
>
<IoMdExpand />
{zoomLevel}%
</button>
<button
onClick={setZoomMode.bind(null, 'fit-width')}
title={_('Zoom In')}
onClick={zoomIn}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-width' && 'bg-base-300/75',
zoomLevel >= MAX_ZOOM_LEVEL && 'btn-disabled text-gray-400',
)}
>
<TbArrowAutofitWidth />
<MdZoomIn />
</button>
</div>
<MenuItem
label={_('Separate Cover Page')}
Icon={keepCoverSpread ? MdCheck : undefined}
onClick={() => setKeepCoverSpread(!keepCoverSpread)}
disabled={spreadMode === 'none'}
/>
<>
<div
title={_('Zoom Mode')}
className={clsx('my-2 flex items-center justify-between rounded-md')}
>
<button
title={_('Single Page')}
onClick={setSpreadMode.bind(null, 'none')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'none' && 'bg-base-300/75',
)}
>
<TbColumns1 />
</button>
<button
title={_('Auto Spread')}
onClick={setSpreadMode.bind(null, 'auto')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'auto' && 'bg-base-300/75',
)}
>
<TbColumns2 />
</button>
<div className='bg-base-300 mx-2 h-6 w-[1px]' />
<button
title={_('Fit Page')}
onClick={setZoomMode.bind(null, 'fit-page')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-page' && 'bg-base-300/75',
)}
>
<IoMdExpand />
</button>
<button
title={_('Fit Width')}
onClick={setZoomMode.bind(null, 'fit-width')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-width' && 'bg-base-300/75',
)}
>
<TbArrowAutofitWidth />
</button>
</div>
<MenuItem
label={_('Separate Cover Page')}
Icon={keepCoverSpread ? MdCheck : undefined}
onClick={() => setKeepCoverSpread(!keepCoverSpread)}
disabled={spreadMode === 'none'}
/>
</>
<hr aria-hidden='true' className='border-base-300 my-1' />
</>
)}
<hr aria-hidden='true' className='border-base-300 my-1' />
<MenuItem label={_('Font & Layout')} shortcut='Shift+F' onClick={openFontLayoutMenu} />
@@ -284,7 +297,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
Icon={invertImgColorInDark ? MdCheck : undefined}
onClick={() => setInvertImgColorInDark(!invertImgColorInDark)}
/>
</div>
</Menu>
);
};
@@ -17,6 +17,7 @@ import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSe
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import useBooksManager from '../../hooks/useBooksManager';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
interface BookMenuProps {
menuClassName?: string;
@@ -87,7 +88,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
};
return (
<div
<Menu
className={clsx('book-menu dropdown-content border-base-100 z-20 shadow-2xl', menuClassName)}
>
<MenuItem
@@ -130,6 +131,12 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
))}
</ul>
</MenuItem>
{bookKeys.length > 1 &&
(parallelViews.length > 0 ? (
<MenuItem label={_('Exit Parallel Read')} onClick={handleUnsetParallel} />
) : (
<MenuItem label={_('Enter Parallel Read')} onClick={handleSetParallel} />
))}
<hr className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
{settings.kosync.enabled && (
@@ -149,7 +156,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<hr className='border-base-200 my-1' />
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
</div>
</Menu>
);
};
@@ -38,7 +38,7 @@ const TabNavigation: React.FC<{
key={tab}
tabIndex={0}
role='button'
className='z-20 m-1.5 flex-1 cursor-pointer rounded-md p-2'
className='z-[11] m-1.5 flex-1 cursor-pointer rounded-md p-2'
onClick={() => onTabChange(tab)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -2,19 +2,22 @@ import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { CFI } from '@/libs/document';
import { Insets } from '@/types/misc';
import { Overlay } from '@/components/Overlay';
import { fetchImageAsBase64 } from '@/utils/image';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -33,6 +36,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
@@ -58,6 +62,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const iconRef = useRef<HTMLDivElement>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
@@ -92,6 +97,53 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
}
};
const initMediaSession = async () => {
const mediaSession = getMediaSession();
if (!mediaSession) return;
mediaSessionRef.current = mediaSession;
if (mediaSession instanceof TauriMediaSession) {
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const { sectionLabel } = progress || {};
let artworkImage = '/icon.png';
try {
artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png');
} catch {
artworkImage = await fetchImageAsBase64('/icon.png');
}
await mediaSession.setActive({
active: true,
keepAppInForeground: settings.alwaysInForeground,
notificationTitle: _('Read Aloud'),
notificationText: _('Ready to read aloud'),
foregroundServiceTitle: _('Read Aloud'),
foregroundServiceText: _('Ready to read aloud'),
});
mediaSession.updateMetadata({
title: title,
artist: sectionLabel || title,
album: author,
artwork: artworkImage,
});
}
};
const deinitMediaSession = async () => {
if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) {
await mediaSessionRef.current.setActive({
active: false,
keepAppInForeground: settings.alwaysInForeground,
});
}
mediaSessionRef.current = null;
};
useEffect(() => {
return () => {
if (ttsControllerRef.current) {
@@ -121,13 +173,24 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const progress = getProgress(bookKey);
const { sectionLabel } = progress || {};
const mark = (e as CustomEvent<TTSMark>).detail;
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
if (mediaSessionRef.current) {
const mediaSession = mediaSessionRef.current;
if (mediaSession instanceof TauriMediaSession) {
mediaSession.updateMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: '',
});
} else {
mediaSession.metadata = new MediaMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
}
}
};
@@ -205,7 +268,10 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
if (appService?.isMobile) {
unblockAudio();
}
await initMediaSession();
setTtsClientsInitialized(false);
setShowIndicator(true);
const ttsController = new TTSController(appService, view);
await ttsController.init();
await ttsController.initViewTTS();
@@ -223,7 +289,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
}
setTtsClientsInitialized(true);
setTTSEnabled(bookKey, true);
setShowIndicator(true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for this document'),
@@ -302,6 +367,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
if (appService?.isMobile) {
releaseUnblockAudio();
}
await deinitMediaSession();
setTTSEnabled(bookKey, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -425,32 +491,33 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
};
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
const { current: mediaSession } = mediaSessionRef;
if (mediaSession) {
mediaSession.setActionHandler('play', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('pause', () => {
mediaSession.setActionHandler('pause', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('stop', () => {
mediaSession.setActionHandler('stop', () => {
handlePause();
});
navigator.mediaSession.setActionHandler('seekforward', () => {
mediaSession.setActionHandler('seekforward', () => {
handleForward(true);
});
navigator.mediaSession.setActionHandler('seekbackward', () => {
mediaSession.setActionHandler('seekbackward', () => {
handleBackward(true);
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
mediaSession.setActionHandler('nexttrack', () => {
handleForward();
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
mediaSession.setActionHandler('previoustrack', () => {
handleBackward();
});
}
@@ -235,7 +235,7 @@ const TTSPanel = ({
</div>
<div className='flex items-center justify-between space-x-2'>
<button
onClick={onBackward}
onClick={() => onBackward()}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Previous Paragraph')}
aria-label={_('Previous Paragraph')}
@@ -255,7 +255,7 @@ const TTSPanel = ({
)}
</button>
<button
onClick={onForward}
onClick={() => onForward()}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Next Paragraph')}
aria-label={_('Next Paragraph')}
@@ -134,11 +134,15 @@ export const useProgressSync = (bookKey: string) => {
content?.index,
bookData.bookDoc,
);
if (CFI.compare(remoteCFILocation, candidateCFI) < 0) {
if (!remoteCFILocation || CFI.compare(remoteCFILocation, candidateCFI) < 0) {
remoteCFILocation = candidateCFI;
}
}
setConfig(bookKey, syncedConfig);
const filteredSyncedConfig = Object.fromEntries(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Object.entries(syncedConfig).filter(([_, value]) => value !== null && value !== undefined),
);
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
if (remoteCFILocation && configCFI) {
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
if (view) {
@@ -263,7 +263,7 @@ export function useTextTranslation(
);
useEffect(() => {
if (viewState?.ttsEnabled && progress && document.hidden) {
if (enabled.current && viewState?.ttsEnabled && progress && document.hidden) {
const { range } = progress;
translateInRange(range);
}
+1 -2
View File
@@ -340,8 +340,7 @@ const ProfilePage = () => {
return (
<div
className={clsx(
'bg-base-100 inset-0 select-none overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
'bg-base-100 inset-0 h-[100vh] select-none overflow-hidden',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
@@ -3,7 +3,7 @@ import Image from 'next/image';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
import { parseWebViewVersion } from '@/utils/ua';
import { parseWebViewInfo } from '@/utils/ua';
import { getAppVersion } from '@/utils/version';
import SupportLinks from './SupportLinks';
import LegalLinks from './LegalLinks';
@@ -30,7 +30,7 @@ export const AboutWindow = () => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setBrowserInfo(parseWebViewVersion(appService));
setBrowserInfo(parseWebViewInfo(appService));
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
@@ -87,9 +87,9 @@ export const AboutWindow = () => {
boxClassName='sm:!w-[480px] sm:!max-w-screen-sm sm:h-auto'
>
{isOpen && (
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center gap-2 px-8'>
<div className='mb-2 mt-8'>
<div className='about-content flex h-full flex-col items-center justify-center gap-4 pb-10 sm:pb-0'>
<div className='flex flex-1 flex-col items-center justify-end gap-2 px-8 py-2'>
<div className='mb-2 mt-6'>
<Image src='/icon.png' alt='App Logo' className='h-20 w-20' width={64} height={64} />
</div>
<div className='flex select-text flex-col items-center'>
@@ -121,9 +121,12 @@ export const AboutWindow = () => {
</div>
</div>
<div className='divider py-16 sm:py-2'></div>
<hr className='border-base-300 my-12 w-full sm:my-4' />
<div className='flex flex-col items-center gap-2 px-4 text-center' dir='ltr'>
<div
className='flex flex-1 flex-col items-center justify-start gap-2 px-4 text-center'
dir='ltr'
>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
@@ -148,8 +151,8 @@ export const AboutWindow = () => {
</p>
<LegalLinks />
<SupportLinks />
</div>
<SupportLinks />
</div>
)}
</Dialog>
+1 -1
View File
@@ -123,7 +123,6 @@ const Dropdown: React.FC<DropdownProps> = ({
ref={containerRef}
role='menu'
tabIndex={-1}
title={label}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={clsx('dropdown flex flex-col', className)}
@@ -132,6 +131,7 @@ const Dropdown: React.FC<DropdownProps> = ({
aria-haspopup='menu'
aria-expanded={isOpen}
aria-label={label}
title={label}
className={clsx(
'dropdown-toggle',
isFocused && isOpen && 'bg-base-300/50',
+3 -2
View File
@@ -3,11 +3,11 @@ import React, { useEffect, useRef } from 'react';
interface MenuProps {
children: React.ReactNode;
label: string;
className?: string;
style?: React.CSSProperties;
}
const Menu: React.FC<MenuProps> = ({ children, className }) => {
const Menu: React.FC<MenuProps> = ({ children, className, style }) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -26,6 +26,7 @@ const Menu: React.FC<MenuProps> = ({ children, className }) => {
ref={menuRef}
role='none'
className={clsx('max-h-[calc(100vh-96px)] overflow-y-auto', className)}
style={style}
>
{children}
</div>
@@ -1,5 +1,6 @@
'use client';
import i18n from '@/i18n/i18n';
import { useEffect } from 'react';
import { IconContext } from 'react-icons';
import { AuthProvider } from '@/context/AuthContext';
@@ -7,13 +8,14 @@ import { useEnv } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import { SyncProvider } from '@/context/SyncContext';
import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { getLocale } from '@/utils/misc';
import i18n from '@/i18n/i18n';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { appService } = useEnv();
const { applyUILanguage } = useSettingsStore();
const iconSize = useDefaultIconSize();
useSafeAreaInsets(); // Initialize safe area insets
@@ -34,8 +36,11 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
loadDataTheme();
if (appService) {
initSystemThemeListener(appService);
appService.loadSettings().then((settings) => {
applyUILanguage(settings.globalViewSettings?.uiLanguage);
});
}
}, [appService]);
}, [appService, applyUILanguage]);
// Make sure appService is available in all children components
if (!appService) return;

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