Compare commits

...

58 Commits

Author SHA1 Message Date
Huang Xin 6de10c6c3b release: version 0.9.85 (#2220) 2025-10-13 19:15:55 +02:00
Huang Xin c7d49c1504 fix: load custom texture before applying it (#2219) 2025-10-13 19:04:45 +02:00
Huang Xin c721bd4d97 chore: enable turbopack by default in development env (#2218) 2025-10-13 16:39:17 +02:00
Huang Xin 5b0a1968d8 chore: add readest.koplugin in the release workflow (#2217) 2025-10-13 16:26:11 +02:00
Huang Xin ad9064fa46 fix(toc): fixed issues of scroll to active items in unpinned sidebar, closes #2213 (#2216) 2025-10-13 15:29:51 +02:00
Huang Xin f1c92e5702 feat: support customizing background images, closes #2018 (#2214) 2025-10-13 14:04:13 +02:00
Huang Xin 92c1441922 translator: add translator target language of Persian, closes #2206 (#2212) 2025-10-12 13:53:23 +02:00
Huang Xin 3820950c7c fix: show only the filtered books in groups (#2209) 2025-10-12 11:38:47 +02:00
Huang Xin 08e558bd41 fix: actually allow script in the transform target, closes #2200 (#2203) 2025-10-11 12:59:55 +02:00
Huang Xin 00af4a3d60 layout: make TTS icon less distracting during read-aloud mode (#2198) 2025-10-10 19:14:57 +02:00
Huang Xin ada427b134 feat(settings): add settings to adjust screen brightness when reading, closes #1532 (#2197) 2025-10-10 19:02:35 +02:00
Huang Xin f696b9a573 fix: handle scrolling/panning in zoomed in PDFs, closes #2184 (#2194) 2025-10-10 07:56:41 +02:00
Huang Xin 195beeacac chore: don't init posthog if users opt-out the usage data telemetry, closes #2187 (#2192) 2025-10-10 07:03:49 +02:00
Huang Xin 5b23ed35b6 txt: properly detect chapters in TXT files with dot-number format, closes #2188 (#2191) 2025-10-10 06:45:50 +02:00
Huang Xin 42c8702704 fix(cbz): fix issue where CBZ files fail to reopen after being closed, closes #2183 (#2190) 2025-10-10 05:50:40 +02:00
Huang Xin ad81376da8 fix(css): auto height of inline images with object-fit style (#2189) 2025-10-10 04:10:05 +02:00
Huang Xin 562fea3a36 fix(layout): fix inconsistent background color for unpinned drag bars (#2186) 2025-10-09 18:35:00 +02:00
Huang Xin b69d9ed69f tts: improve media session control compatibility across more Android systems (#2185) 2025-10-09 17:53:07 +02:00
Huang Xin ad6a21a68f refactor: split FooterBar into modular components for desktop and mobile (#2181) 2025-10-08 07:47:39 +02:00
Huang Xin 173aa0fcd1 layout: fix dragbar flickering when opening a book (#2177) 2025-10-06 05:52:36 +02:00
Huang Xin 788e08a75a shortcut: use shift+space to go backward in the reader page (#2176) 2025-10-06 05:31:16 +02:00
Huang Xin ec397a2daf fix: draw annotations of the current section on load, closes #2163 (#2174) 2025-10-05 17:47:34 +02:00
Huang Xin 2a5758fbc4 compat: disable customize root dir for macOS App Store builds (#2171)
CustomizeRootDir has a blocker on macOS App Store builds due to Security Scoped Resource restrictions.
See: https://github.com/tauri-apps/tauri/issues/3716
2025-10-05 08:28:45 +02:00
Huang Xin 562ccd4b9e feat: add keyboard shortcuts for toggling bookmarks/sidebar, importing books, closing window (#2170)
And make the shortcuts compatible with GNOME Human Interface Guidelines.
This closes #2079 and closes #2168.
2025-10-04 16:12:31 +02:00
make_aguess 4d577774f3 Adds adaptive icon for Android launcher (#2162)
Introduces an adaptive icon for the Android launcher, defining
separate layers for background, foreground, and monochrome
elements. Enhances visual consistency and supports adaptive
icon features on modern Android devices.
2025-10-04 09:00:33 +02:00
Huang Xin 4c10c16491 i18n: add translations for Swedish(sv) (#2159) 2025-10-02 14:25:01 +02:00
Huang Xin 7695f31a5a translator: translate to and from Norwegian Bokmål(nb) (#2158) 2025-10-02 14:09:01 +02:00
Huang Xin 06b27f8d39 chore: fix portable exe build for Windows (#2156) 2025-10-02 00:11:21 +08:00
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
203 changed files with 7846 additions and 1982 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
+33 -3
View File
@@ -81,6 +81,28 @@ jobs:
prerelease: false
})
build-koreader-plugin:
needs: get-release
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: create KOReader plugin zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version=${{ needs.get-release.outputs.release_version }}
plugin_zip="Readest-${version}-1.koplugin.zip"
cd apps/readest.koplugin
zip -r ../../${plugin_zip} .
cd ../..
echo "Uploading ${plugin_zip} to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
build-tauri:
needs: get-release
permissions:
@@ -153,7 +175,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 +222,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 +310,20 @@ 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 }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
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" }
@@ -18,6 +18,7 @@ module.exports = {
'ko',
'uk',
'nl',
'sv',
'pl',
'pt',
'ru',
+13 -12
View File
@@ -1,12 +1,12 @@
{
"name": "@readest/readest-app",
"version": "0.9.81",
"version": "0.9.85",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
"dev": "dotenv -e .env.tauri -- next dev --turbopack",
"build": "dotenv -e .env.tauri -- next build",
"start": "dotenv -e .env.tauri -- next start",
"dev-web": "dotenv -e .env.web -- next dev",
"dev-web": "dotenv -e .env.web -- next dev --turbopack",
"build-web": "dotenv -e .env.web -- next build",
"start-web": "dotenv -e .env.web -- next start",
"i18n:extract": "i18next-scanner",
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

@@ -579,5 +579,48 @@
"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": "القراءة بصوت عالٍ",
"Screen Brightness": "سطوع الشاشة",
"Background Image": "صورة الخلفية",
"Import Image": "استيراد صورة",
"Opacity": "شفافية",
"Size": "حجم",
"Cover": "غطاء",
"Contain": "احتواء"
}
@@ -563,5 +563,48 @@
"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": "উচ্চস্বরে পড়ুন",
"Screen Brightness": "স্ক্রিন উজ্জ্বলতা",
"Background Image": "ব্যাকগ্রাউন্ড ছবি",
"Import Image": "ছবি আমদানি করুন",
"Opacity": "স্বচ্ছতা",
"Size": "আকার",
"Cover": "ঢাকা",
"Contain": "ধারণ"
}
@@ -539,25 +539,68 @@
"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": "ཀློག་བྱེད།",
"Screen Brightness": "རྒྱབ་སྐོར་གྱི་འོད་ཟེར།",
"Background Image": "གནས་སྟངས་ཀྱི་རི་མོ།",
"Import Image": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Opacity": "སྤོ་སྐོར་གྱི་འོད་ཟེར།",
"Size": "ཨང་",
"Cover": "དེབ་གྱི་སྤྱོད་བྱས་མ་ཐུབ།",
"Contain": "འབྱོར་བ།"
}
@@ -563,5 +563,48 @@
"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",
"Screen Brightness": "Bildschirmhelligkeit",
"Background Image": "Hintergrundbild",
"Import Image": "Bild importieren",
"Opacity": "Opazität",
"Size": "Größe",
"Cover": "Cover",
"Contain": "Inhalt"
}
@@ -563,5 +563,48 @@
"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": "Ανάγνωση",
"Screen Brightness": "Φωτεινότητα οθόνης",
"Background Image": "Εικόνα φόντου",
"Import Image": "Εισαγωγή εικόνας",
"Opacity": "Αδιαφάνεια",
"Size": "Μέγεθος",
"Cover": "Εξώφυλλο",
"Contain": "Περιέχει"
}
@@ -567,5 +567,48 @@
"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",
"Screen Brightness": "Brillo de pantalla",
"Background Image": "Imagen de fondo",
"Import Image": "Importar imagen",
"Opacity": "Opacidad",
"Size": "Tamaño",
"Cover": "Cubierta",
"Contain": "Contener"
}
@@ -567,5 +567,48 @@
"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",
"Screen Brightness": "Luminosité de l'Écran",
"Background Image": "Image de Fond",
"Import Image": "Importer une Image",
"Opacity": "Opacité",
"Size": "Taille",
"Cover": "Couverture",
"Contain": "Contenir"
}
@@ -563,5 +563,48 @@
"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": "उच्च स्वर में पढ़ें",
"Screen Brightness": "स्क्रीन ब्राइटनेस",
"Background Image": "पृष्ठभूमि छवि",
"Import Image": "छवि आयात करें",
"Opacity": "अस्पष्टता",
"Size": "आकार",
"Cover": "कवर",
"Contain": "समाहित करें"
}
@@ -559,5 +559,48 @@
"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",
"Screen Brightness": "Kecerahan Layar",
"Background Image": "Gambar Latar Belakang",
"Import Image": "Impor Gambar",
"Opacity": "Keburaman",
"Size": "Ukuran",
"Cover": "Sampul",
"Contain": "Mengandung"
}
@@ -567,5 +567,48 @@
"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",
"Screen Brightness": "Luminosità dello schermo",
"Background Image": "Immagine di sfondo",
"Import Image": "Importa immagine",
"Opacity": "Opacità",
"Size": "Dimensione",
"Cover": "Copertura",
"Contain": "Contenere"
}
@@ -559,5 +559,48 @@
"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": "読み上げ",
"Screen Brightness": "画面の明るさ",
"Background Image": "背景画像",
"Import Image": "画像をインポート",
"Opacity": "不透明度",
"Size": "サイズ",
"Cover": "カバー",
"Contain": "含む"
}
@@ -559,5 +559,48 @@
"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": "음성 읽기",
"Screen Brightness": "화면 밝기",
"Background Image": "배경 이미지",
"Import Image": "이미지 가져오기",
"Opacity": "불투명도",
"Size": "크기",
"Cover": "표지",
"Contain": "포함"
}
@@ -563,5 +563,48 @@
"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",
"Screen Brightness": "Schermhelderheid",
"Background Image": "Achtergrondafbeelding",
"Import Image": "Afbeelding importeren",
"Opacity": "Doorzichtigheid",
"Size": "Grootte",
"Cover": "Omslag",
"Contain": "Bevatten"
}
@@ -571,5 +571,48 @@
"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",
"Screen Brightness": "Jasność ekranu",
"Background Image": "Obraz tła",
"Import Image": "Importuj obraz",
"Opacity": "Przezroczystość",
"Size": "Rozmiar",
"Cover": "Okładka",
"Contain": "Zawierać"
}
@@ -567,5 +567,48 @@
"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",
"Screen Brightness": "Brilho da Tela",
"Background Image": "Imagem de Fundo",
"Import Image": "Importar Imagem",
"Opacity": "Opacidade",
"Size": "Tamanho",
"Cover": "Capa",
"Contain": "Contém"
}
@@ -571,5 +571,48 @@
"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": "Читать вслух",
"Screen Brightness": "Яркость экрана",
"Background Image": "Фоновое изображение",
"Import Image": "Импортировать изображение",
"Opacity": "Непрозрачность",
"Size": "Размер",
"Cover": "Обложка",
"Contain": "Содержать"
}
@@ -563,5 +563,48 @@
"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": "පවසා කියවීම",
"Screen Brightness": "තිරයේ දිදුලනුම",
"Background Image": "පසුබිම් රූපය",
"Import Image": "රූපය ආයාත කරන්න",
"Opacity": "පැහැදිලිත්වය",
"Size": "ප්‍රමාණය",
"Cover": "ආවරණය",
"Contain": "අඩංගු වන්න"
}
@@ -0,0 +1,610 @@
{
"Email address": "E-postadress",
"Your Password": "Ditt lösenord",
"Your email address": "Din e-postadress",
"Your password": "Ditt lösenord",
"Sign in": "Logga in",
"Signing in...": "Loggar in...",
"Sign in with {{provider}}": "Logga in med {{provider}}",
"Already have an account? Sign in": "Har du konto? Logga in",
"Create a Password": "Skapa lösenord",
"Sign up": "Registrera",
"Signing up...": "Registrerar...",
"Don't have an account? Sign up": "Inget konto? Registrera",
"Check your email for the confirmation link": "Kolla din e-post för bekräftelselänk",
"Signing in ...": "Loggar in...",
"Send a magic link email": "Skicka magisk länk",
"Check your email for the magic link": "Kolla din e-post för magisk länk",
"Send reset password instructions": "Skicka återställningsinstruktioner",
"Sending reset instructions ...": "Skickar instruktioner...",
"Forgot your password?": "Glömt lösenord?",
"Check your email for the password reset link": "Kolla din e-post för återställningslänk",
"Phone number": "Telefonnummer",
"Your phone number": "Ditt telefonnummer",
"Token": "Token",
"Your OTP token": "Din OTP-token",
"Verify token": "Verifiera token",
"Go Back": "Tillbaka",
"Sign in with Google": "Logga in med Google",
"Sign in with Apple": "Logga in med Apple",
"Sign in with GitHub": "Logga in med GitHub",
"New Password": "Nytt lösenord",
"Your new password": "Ditt nya lösenord",
"Update password": "Uppdatera lösenord",
"Updating password ...": "Uppdaterar lösenord...",
"Your password has been updated": "Ditt lösenord har uppdaterats",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Något gick fel. Oroa dig inte, vårt team har meddelats och vi arbetar på en lösning.",
"Error Details:": "Feldetaljer:",
"Try Again": "Försök igen",
"Your Library": "Ditt bibliotek",
"Need help?": "Behöver du hjälp?",
"Contact Support": "Kontakta support",
"Show Book Details": "Visa bokdetaljer",
"Bookshelf": "Bokhylla",
"Import Books": "Importera böcker",
"Open": "Öppna",
"Group": "Grupp",
"Details": "Detaljer",
"Delete": "Ta bort",
"Cancel": "Avbryt",
"Confirm Deletion": "Bekräfta borttagning",
"Are you sure to delete {{count}} selected book(s)?_one": "Vill du ta bort {{count}} vald bok?",
"Are you sure to delete {{count}} selected book(s)?_other": "Vill du ta bort {{count}} valda böcker?",
"Deselect Book": "Avmarkera bok",
"Select Book": "Välj bok",
"Download Book": "Ladda ner bok",
"Upload Book": "Ladda upp bok",
"Deselect Group": "Avmarkera grupp",
"Select Group": "Välj grupp",
"Untitled Group": "Namnlös grupp",
"Group Books": "Gruppera böcker",
"Remove From Group": "Ta bort från grupp",
"Create New Group": "Skapa ny grupp",
"Save": "Spara",
"Confirm": "Bekräfta",
"From Local File": "Från lokal fil",
"Search in {{count}} Book(s)..._one": "Sök i {{count}} bok...",
"Search in {{count}} Book(s)..._other": "Sök i {{count}} böcker...",
"Search Books...": "Sök böcker...",
"Clear Search": "Rensa sökning",
"Select Books": "Välj böcker",
"Deselect": "Avmarkera",
"Select All": "Välj alla",
"View Menu": "Visa meny",
"Settings Menu": "Inställningsmeny",
"Failed to select directory": "Kunde inte välja katalog",
"The new data directory must be different from the current one.": "Den nya datakatalogen måste skilja sig från den nuvarande.",
"Migration failed: {{error}}": "Migrering misslyckades: {{error}}",
"Change Data Location": "Ändra dataplats",
"Current Data Location": "Nuvarande dataplats",
"Loading...": "Laddar...",
"File count: {{size}}": "Antal filer: {{size}}",
"Total size: {{size}}": "Total storlek: {{size}}",
"Calculating file info...": "Beräknar filinfo...",
"New Data Location": "Ny dataplats",
"Choose New Folder": "Välj ny mapp",
"Choose Different Folder": "Välj annan mapp",
"Migrating data...": "Migrerar data...",
"Copying: {{file}}": "Kopierar: {{file}}",
"{{current}} of {{total}} files": "{{current}} av {{total}} filer",
"Migration completed successfully!": "Migrering slutförd!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Din data har flyttats till den nya platsen. Starta om appen för att slutföra processen.",
"Migration failed": "Migrering misslyckades",
"Important Notice": "Viktigt meddelande",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Detta flyttar all din appdata till den nya platsen. Se till att målet har tillräckligt med utrymme.",
"Close": "Stäng",
"Restart App": "Starta om app",
"Start Migration": "Starta migrering",
"Dark Mode": "Mörkt läge",
"Light Mode": "Ljust läge",
"Auto Mode": "Auto-läge",
"Logged in as {{userDisplayName}}": "Inloggad som {{userDisplayName}}",
"Logged in": "Inloggad",
"View account details and quota": "Visa kontodetaljer och kvot",
"Account": "Konto",
"Sign In": "Logga in",
"Auto Upload Books to Cloud": "Auto-ladda upp böcker till moln",
"Auto Import on File Open": "Auto-importera vid filöppning",
"Open Last Book on Start": "Öppna senaste bok vid start",
"Check Updates on Start": "Sök uppdateringar vid start",
"Open Book in New Window": "Öppna bok i nytt fönster",
"Fullscreen": "Helskärm",
"Always on Top": "Alltid överst",
"Always Show Status Bar": "Visa alltid statusfält",
"Keep Screen Awake": "Håll skärm vaken",
"Background Read Aloud": "Läs upp i bakgrunden",
"Reload Page": "Ladda om sida",
"Settings": "Inställningar",
"Advanced Settings": "Avancerade inställningar",
"Upgrade to Readest Premium": "Uppgradera till Readest Premium",
"Download Readest": "Ladda ner Readest",
"About Readest": "Om Readest",
"Help improve Readest": "Hjälp förbättra Readest",
"Sharing anonymized statistics": "Dela anonymiserad statistik",
"List": "Lista",
"Grid": "Rutnät",
"Crop": "Beskär",
"Fit": "Anpassa",
"Title": "Titel",
"Author": "Författare",
"Format": "Format",
"Date Read": "Läsdatum",
"Date Added": "Datum tillagd",
"Ascending": "Stigande",
"Descending": "Fallande",
"Book Covers": "Bokomslag",
"Sort by...": "Sortera efter...",
"No supported files found. Supported formats: {{formats}}": "Inga filer hittades. Format som stöds: {{formats}}",
"No chapters detected.": "Inga kapitel hittades.",
"Failed to parse the EPUB file.": "Kunde inte läsa EPUB-filen.",
"This book format is not supported.": "Detta bokformat stöds inte.",
"Failed to import book(s): {{filenames}}": "Kunde inte importera bok(er): {{filenames}}",
"Book uploaded: {{title}}": "Bok uppladdad: {{title}}",
"Insufficient storage quota": "Otillräcklig lagringskvot",
"Failed to upload book: {{title}}": "Kunde inte ladda upp bok: {{title}}",
"Book downloaded: {{title}}": "Bok nedladdad: {{title}}",
"Failed to download book: {{title}}": "Kunde inte ladda ner bok: {{title}}",
"Book deleted: {{title}}": "Bok borttagen: {{title}}",
"Deleted cloud backup of the book: {{title}}": "Raderade molnkopia av bok: {{title}}",
"Deleted local copy of the book: {{title}}": "Raderade lokal kopia av bok: {{title}}",
"Failed to delete book: {{title}}": "Kunde inte ta bort bok: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Kunde inte radera molnkopia av bok: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Kunde inte radera lokal kopia av bok: {{title}}",
"Library Header": "Bibliotekshuvud",
"Welcome to your library. You can import your books here and read them anytime.": "Välkommen till ditt bibliotek. Du kan importera dina böcker här och läsa dem när som helst.",
"Copied to notebook": "Kopierat till anteckningsbok",
"No annotations to export": "Inga anteckningar att exportera",
"Exported from Readest": "Exporterad från Readest",
"Highlights & Annotations": "Markeringar & anteckningar",
"Untitled": "Namnlös",
"Note": "Anteckning",
"Copied to clipboard": "Kopierat",
"Copy": "Kopiera",
"Delete Highlight": "Ta bort markering",
"Highlight": "Markera",
"Annotate": "Anteckna",
"Search": "Sök",
"Dictionary": "Ordbok",
"Wikipedia": "Wikipedia",
"Translate": "Översätt",
"Speak": "Läs upp",
"Login Required": "Inloggning krävs",
"Quota Exceeded": "Kvot överskriden",
"Unable to fetch the translation. Please log in first and try again.": "Kunde inte hämta översättning. Logga in och försök igen.",
"Unable to fetch the translation. Try again later.": "Kunde inte hämta översättning. Försök senare.",
"Original Text": "Originaltext",
"Auto Detect": "Auto-identifiera",
"(detected)": "(identifierad)",
"Translated Text": "Översatt text",
"System Language": "Systemspråk",
"No translation available.": "Ingen översättning tillgänglig.",
"Translated by {{provider}}.": "Översatt av {{provider}}.",
"Remove Bookmark": "Ta bort bokmärke",
"Add Bookmark": "Lägg till bokmärke",
"Books Content": "Bokinnehåll",
"Book Content": "Bokinnehåll",
"Footer Bar": "Sidfot",
"Reading Progress": "Läsframsteg",
"Next Section": "Nästa sektion",
"Previous Section": "Föregående sektion",
"Next Page": "Nästa sida",
"Previous Page": "Föregående sida",
"Go Forward": "Framåt",
"Font Size": "Teckenstorlek",
"Page Margin": "Sidmarginal",
"Small": "Liten",
"Large": "Stor",
"Line Spacing": "Radavstånd",
"Table of Contents": "Innehåll",
"Notes": "Anteckningar",
"Font & Layout": "Typsnitt & layout",
"Jump to Location": "Hoppa till plats",
"Header Bar": "Sidhuvud",
"View Options": "Visningsalternativ",
"Sync Conflict": "Synkkonflikt",
"Sync reading progress from \"{{deviceName}}\"?": "Synka läsframsteg från \"{{deviceName}}\"?",
"another device": "annan enhet",
"Local Progress": "Lokalt framsteg",
"Remote Progress": "Fjärrframsteg",
"Failed to connect": "Kunde inte ansluta",
"Disconnected": "Frånkopplad",
"KOReader Sync Settings": "KOReader-synkinställningar",
"Sync as {{userDisplayName}}": "Synka som {{userDisplayName}}",
"Sync Server Connected": "Synkserver ansluten",
"Sync Strategy": "Synkstrategi",
"Ask on conflict": "Fråga vid konflikt",
"Always use latest": "Använd alltid senaste",
"Send changes only": "Skicka endast ändringar",
"Receive changes only": "Ta endast emot ändringar",
"Checksum Method": "Kontrollsummemetod",
"File Content (recommended)": "Filinnehåll (rekommenderas)",
"File Name": "Filnamn",
"Device Name": "Enhetsnamn",
"Connect to your KOReader Sync server.": "Anslut till din KOReader-synkserver.",
"Server URL": "Server-URL",
"Username": "Användarnamn",
"Your Username": "Ditt användarnamn",
"Password": "Lösenord",
"Connect": "Anslut",
"Notebook": "Anteckningsbok",
"Unpin Notebook": "Lossa anteckningsbok",
"Pin Notebook": "Fäst anteckningsbok",
"Hide Search Bar": "Dölj sökfält",
"Show Search Bar": "Visa sökfält",
"Resize Notebook": "Ändra storlek på anteckningsbok",
"No notes match your search": "Inga anteckningar matchar din sökning",
"Excerpts": "Utdrag",
"Add your notes here...": "Lägg till dina anteckningar här...",
"Search notes and excerpts...": "Sök anteckningar och utdrag...",
"{{time}} min left in chapter": "{{time}} min kvar i kapitlet",
"{{count}} pages left in chapter_one": "{{count}} sida kvar i kapitlet",
"{{count}} pages left in chapter_other": "{{count}} sidor kvar i kapitlet",
"On {{current}} of {{total}} page": "På {{current}} av {{total}} sidor",
"Section Title": "Sektionstitel",
"Book Cover": "Bokomslag",
"More Info": "Mer info",
"Parallel Read": "Parallell läsning",
"Disable": "Inaktivera",
"Enable": "Aktivera",
"Exit Parallel Read": "Avsluta parallell läsning",
"Enter Parallel Read": "Starta parallell läsning",
"KOReader Sync": "KOReader-synk",
"Push Progress": "Skicka framsteg",
"Pull Progress": "Hämta framsteg",
"Export Annotations": "Exportera anteckningar",
"Sort TOC by Page": "Sortera innehåll efter sida",
"Edit": "Redigera",
"Go to Library": "Gå till bibliotek",
"Book Menu": "Bokmeny",
"Unpin Sidebar": "Lossa sidofält",
"Pin Sidebar": "Fäst sidofält",
"Search...": "Sök...",
"Search Options": "Sökalternativ",
"Book": "Bok",
"Chapter": "Kapitel",
"Match Case": "Matchning av versaler",
"Match Whole Words": "Matchning av hela ord",
"Match Diacritics": "Matchning av diakritiska tecken",
"Sidebar": "Sidofält",
"Resize Sidebar": "Ändra storlek på sidofält",
"TOC": "Innehåll",
"Bookmark": "Bokmärke",
"Toggle Sidebar": "Visa/dölj sidofält",
"Toggle Translation": "Visa/dölj översättning",
"Disable Translation": "Inaktivera översättning",
"Enable Translation": "Aktivera översättning",
"Translation Disabled": "Översättning inaktiverad",
"Previous Paragraph": "Föregående stycke",
"Previous Sentence": "Föregående mening",
"Pause": "Pausa",
"Play": "Spela",
"Next Sentence": "Nästa mening",
"Next Paragraph": "Nästa stycke",
"Read Aloud": "Läs upp",
"Ready to read aloud": "Redo att läsa upp",
"TTS not supported for PDF": "Uppläsning stöds inte för PDF",
"TTS not supported for this document": "Uppläsning stöds inte för detta dokument",
"No Timeout": "Ingen timeout",
"{{value}} minute": "{{value}} minut",
"{{value}} minutes": "{{value}} minuter",
"{{value}} hour": "{{value}} timme",
"{{value}} hours": "{{value}} timmar",
"Voices for {{lang}}": "Röster för {{lang}}",
"Slow": "Långsam",
"Fast": "Snabb",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} röst",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} röster",
"Zoom Level": "Zoomnivå",
"Zoom Out": "Zooma ut",
"Reset Zoom": "Återställ zoom",
"Zoom In": "Zooma in",
"Zoom Mode": "Zoomläge",
"Single Page": "Enkel sida",
"Auto Spread": "Auto-uppslag",
"Fit Page": "Anpassa sida",
"Fit Width": "Anpassa bredd",
"Separate Cover Page": "Separat omslagssida",
"Scrolled Mode": "Rullningsläge",
"Sign in to Sync": "Logga in för synk",
"Synced at {{time}}": "Synkad {{time}}",
"Never synced": "Aldrig synkad",
"Invert Image In Dark Mode": "Invertera bild i mörkt läge",
"Reading Progress Synced": "Läsframsteg synkat",
"Page {{page}} of {{total}} ({{percentage}}%)": "Sida {{page}} av {{total}} ({{percentage}}%)",
"Current position": "Nuvarande position",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Cirka sida {{page}} av {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Cirka {{percentage}}%",
"Delete Your Account?": "Ta bort ditt konto?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Detta kan inte ångras. All din data i molnet raderas permanent.",
"Delete Permanently": "Ta bort permanent",
"Restore Purchase": "Återställ köp",
"Manage Subscription": "Hantera prenumeration",
"Reset Password": "Återställ lösenord",
"Sign Out": "Logga ut",
"Delete Account": "Ta bort konto",
"Upgrade to {{plan}}": "Uppgradera till {{plan}}",
"Complete Your Subscription": "Slutför din prenumeration",
"Coming Soon": "Kommer snart",
"Upgrade to Plus or Pro": "Uppgradera till Plus eller Pro",
"Current Plan": "Nuvarande plan",
"Plan Limits": "Plangränser",
"Failed to delete user. Please try again later.": "Kunde inte ta bort användare. Försök senare.",
"Failed to create checkout session": "Kunde inte skapa betalningssession",
"No purchases found to restore.": "Inga köp att återställa.",
"Failed to restore purchases.": "Kunde inte återställa köp.",
"Failed to manage subscription.": "Kunde inte hantera prenumeration.",
"Failed to load subscription plans.": "Kunde inte ladda prenumerationsplaner.",
"Loading profile...": "Laddar profil...",
"Processing your payment...": "Behandlar din betalning...",
"Please wait while we confirm your subscription.": "Vänta medan vi bekräftar din prenumeration.",
"Payment Processing": "Betalning behandlas",
"Your payment is being processed. This usually takes a few moments.": "Din betalning behandlas. Detta tar vanligtvis några ögonblick.",
"Payment Failed": "Betalning misslyckades",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "Vi kunde inte behandla din prenumeration. Försök igen eller kontakta support om problemet kvarstår.",
"Back to Profile": "Tillbaka till profil",
"Subscription Successful!": "Prenumeration lyckades!",
"Thank you for your subscription! Your payment has been processed successfully.": "Tack för din prenumeration! Din betalning har behandlats.",
"Email:": "E-post:",
"Plan:": "Plan:",
"Amount:": "Belopp:",
"Subscription ID:": "Prenumerations-ID:",
"Need help? Contact our support team at support@readest.com": "Behöver du hjälp? Kontakta vår support på support@readest.com",
"Free Plan": "Gratisplan",
"month": "månad",
"year": "år",
"Cross-Platform Sync": "Plattformsoberoende synk",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "Synka sömlöst ditt bibliotek, framsteg, markeringar och anteckningar mellan alla dina enheter.",
"Customizable Reading": "Anpassningsbar läsning",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "Anpassa varje detalj med justerbara typsnitt, layouter, teman och avancerade visningsinställningar.",
"AI Read Aloud": "AI-uppläsning",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "Njut av handsfree-läsning med naturligt ljudande AI-röster som ger dina böcker liv.",
"AI Translations": "AI-översättningar",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Översätt vilken text som helst direkt med Google, Azure eller DeepL.",
"Community Support": "Community-support",
"Connect with fellow readers and get help fast in our friendly community channels.": "Anslut med andra läsare och få snabb hjälp i våra vänliga community-kanaler.",
"Cloud Sync Storage": "Molnsynklagring",
"AI Translations (per day)": "AI-översättningar (per dag)",
"Plus Plan": "Plus-plan",
"Includes All Free Plan Benefits": "Inkluderar alla gratisplanfördelar",
"Unlimited AI Read Aloud Hours": "Obegränsad AI-uppläsning",
"Listen without limits—convert as much text as you like into immersive audio.": "Lyssna obegränsat—konvertera så mycket text du vill till ljud.",
"More AI Translations": "Fler AI-översättningar",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Lås upp förbättrad översättning med mer daglig användning och avancerade alternativ.",
"DeepL Pro Access": "DeepL Pro-åtkomst",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Översätt upp till 100 000 tecken dagligen med den mest exakta översättningsmotorn.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Lagra säkert hela din lässamling med upp till 5 GB molnlagring.",
"Priority Support": "Prioriterad support",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Få snabbare svar och dedikerad hjälp när du behöver det.",
"Pro Plan": "Pro-plan",
"Includes All Plus Plan Benefits": "Inkluderar alla Plus-planfördelar",
"Early Feature Access": "Tidig funktionsåtkomst",
"Be the first to explore new features, updates, and innovations before anyone else.": "Var först att utforska nya funktioner och uppdateringar innan andra.",
"Advanced AI Tools": "Avancerade AI-verktyg",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Använd kraftfulla AI-verktyg för smartare läsning, översättning och innehållsupptäckt.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Översätt upp till 500 000 tecken dagligen med den mest exakta översättningsmotorn.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Lagra säkert hela din lässamling med upp till 20 GB molnlagring.",
"Version {{version}}": "Version {{version}}",
"Check Update": "Sök uppdatering",
"Already the latest version": "Redan senaste versionen",
"Checking for updates...": "Söker uppdateringar...",
"Error checking for updates": "Fel vid uppdateringskontroll",
"Drop to Import Books": "Släpp för att importera böcker",
"Terms of Service": "Användarvillkor",
"Privacy Policy": "Integritetspolicy",
"ON": "PÅ",
"OFF": "AV",
"Enter book title": "Ange boktitel",
"Subtitle": "Undertitel",
"Enter book subtitle": "Ange underrubrik",
"Enter author name": "Ange författarnamn",
"Series": "Serie",
"Enter series name": "Ange serienamn",
"Series Index": "Serieindex",
"Enter series index": "Ange serieindex",
"Total in Series": "Totalt i serien",
"Enter total books in series": "Ange totalt antal böcker i serien",
"Publisher": "Förlag",
"Enter publisher": "Ange förlag",
"Publication Date": "Publiceringsdatum",
"YYYY or YYYY-MM-DD": "ÅÅÅÅ eller ÅÅÅÅ-MM-DD",
"Language": "Språk",
"Identifier": "Identifierare",
"Subjects": "Ämnen",
"Fiction, Science, History": "Skönlitteratur, Vetenskap, Historia",
"Description": "Beskrivning",
"Enter book description": "Ange bokbeskrivning",
"Change cover image": "Ändra omslagsbild",
"Replace": "Ersätt",
"Remove cover image": "Ta bort omslagsbild",
"Unlock cover": "Lås upp omslag",
"Lock cover": "Lås omslag",
"Auto-Retrieve Metadata": "Auto-hämta metadata",
"Auto": "Auto",
"Auto-Retrieve": "Auto-hämta",
"Unlock all fields": "Lås upp alla fält",
"Unlock All": "Lås upp alla",
"Lock all fields": "Lås alla fält",
"Lock All": "Lås alla",
"Reset": "Återställ",
"Are you sure to delete the selected book?": "Vill du ta bort den valda boken?",
"Are you sure to delete the cloud backup of the selected book?": "Vill du ta bort molnkopian av den valda boken?",
"Are you sure to delete the local copy of the selected book?": "Vill du ta bort den lokala kopian av den valda boken?",
"Edit Metadata": "Redigera metadata",
"Book Details": "Bokdetaljer",
"Unknown": "Okänd",
"Delete Book Options": "Ta bort bokalternativ",
"Remove from Cloud & Device": "Ta bort från moln & enhet",
"Remove from Cloud Only": "Ta bort endast från moln",
"Remove from Device Only": "Ta bort endast från enhet",
"Published": "Publicerad",
"Updated": "Uppdaterad",
"Added": "Tillagd",
"File Size": "Filstorlek",
"No description available": "Ingen beskrivning tillgänglig",
"Locked": "Låst",
"Select Metadata Source": "Välj metadatakälla",
"Keep manual input": "Behåll manuell inmatning",
"Theme Mode": "Temaläge",
"Override Book Color": "Åsidosätt bokfärg",
"Theme Color": "Temafärg",
"Custom": "Anpassad",
"Code Highlighting": "Kodmarkering",
"Enable Highlighting": "Aktivera markering",
"Code Language": "Kodspråk",
"Scroll": "Rullning",
"Continuous Scroll": "Kontinuerlig rullning",
"Overlap Pixels": "Överlappande pixlar",
"Click": "Klick",
"Clicks for Page Flip": "Klick för sidbyte",
"Swap Clicks Area": "Byt klickområde",
"Disable Double Click": "Inaktivera dubbelklick",
"Volume Keys for Page Flip": "Volymknappar för sidbyte",
"Animation": "Animation",
"Paging Animation": "Sidanimation",
"Security": "Säkerhet",
"Allow JavaScript": "Tillåt JavaScript",
"Enable only if you trust the file.": "Aktivera endast om du litar på filen.",
"Font": "Typsnitt",
"Custom Fonts": "Anpassade typsnitt",
"Cancel Delete": "Avbryt borttagning",
"Delete Font": "Ta bort typsnitt",
"Import Font": "Importera typsnitt",
"Tips": "Tips",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Format som stöds: .ttf, .otf, .woff, .woff2",
"Custom fonts can be selected from the Font Face menu": "Anpassade typsnitt kan väljas från typsnittsmeny",
"Global Settings": "Globala inställningar",
"Apply to All Books": "Tillämpa på alla böcker",
"Apply to This Book": "Tillämpa på denna bok",
"Reset Settings": "Återställ inställningar",
"Manage Custom Fonts": "Hantera anpassade typsnitt",
"System Fonts": "Systemtypsnitt",
"Serif Font": "Serif-typsnitt",
"Sans-Serif Font": "Sans-serif-typsnitt",
"Override Book Font": "Åsidosätt boktypsnitt",
"Default Font Size": "Standard teckenstorlek",
"Minimum Font Size": "Minsta teckenstorlek",
"Font Weight": "Typsnittsvikt",
"Font Family": "Typsnittsfamilj",
"Default Font": "Standardtypsnitt",
"CJK Font": "CJK-typsnitt",
"Font Face": "Typsnittssnitt",
"Monospace Font": "Monospace-typsnitt",
"Interface Language": "Gränssnittsspråk",
"Translation": "Översättning",
"Show Source Text": "Visa källtext",
"Translation Service": "Översättningstjänst",
"Translate To": "Översätt till",
"Override Book Layout": "Åsidosätt boklayout",
"Writing Mode": "Skrivläge",
"Default": "Standard",
"Horizontal Direction": "Horisontell riktning",
"Vertical Direction": "Vertikal riktning",
"RTL Direction": "RTL-riktning",
"Border Frame": "Kantram",
"Double Border": "Dubbel kant",
"Border Color": "Kantfärg",
"Paragraph": "Stycke",
"Paragraph Margin": "Styckemarginal",
"Word Spacing": "Ordavstånd",
"Letter Spacing": "Bokstavsavstånd",
"Text Indent": "Textindrag",
"Full Justification": "Full justering",
"Hyphenation": "Avstavning",
"Page": "Sida",
"Top Margin (px)": "Övre marginal (px)",
"Bottom Margin (px)": "Nedre marginal (px)",
"Left Margin (px)": "Vänster marginal (px)",
"Right Margin (px)": "Höger marginal (px)",
"Column Gap (%)": "Kolumnavstånd (%)",
"Maximum Number of Columns": "Max antal kolumner",
"Maximum Column Height": "Max kolumnhöjd",
"Maximum Column Width": "Max kolumnbredd",
"Header & Footer": "Sidhuvud & sidfot",
"Show Header": "Visa sidhuvud",
"Show Footer": "Visa sidfot",
"Show Remaining Time": "Visa återstående tid",
"Show Remaining Pages": "Visa återstående sidor",
"Show Reading Progress": "Visa läsframsteg",
"Reading Progress Style": "Läsframstegsstil",
"Page Number": "Sidnummer",
"Percentage": "Procent",
"Apply also in Scrolled Mode": "Tillämpa även i rullningsläge",
"Screen": "Skärm",
"Orientation": "Orientering",
"Portrait": "Porträtt",
"Landscape": "Landskap",
"Apply": "Tillämpa",
"Custom Content CSS": "Anpassad innehålls-CSS",
"Enter CSS for book content styling...": "Ange CSS för bokinnehållsstil...",
"Custom Reader UI CSS": "Anpassad läsar-UI CSS",
"Enter CSS for reader interface styling...": "Ange CSS för läsargränssnittsstil...",
"Decrease": "Minska",
"Increase": "Öka",
"Layout": "Layout",
"Color": "Färg",
"Behavior": "Beteende",
"Settings Panels": "Inställningspaneler",
"Reset {{settings}}": "Återställ {{settings}}",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Hela världen är en scen,\nOch alla män och kvinnor bara spelare;\nDe har sina utgångar och entréer,\nOch en man spelar i sin tid många roller,\nHans akter är sju åldrar.\n\n— William Shakespeare",
"(from 'As You Like It', Act II)": "(från 'Som ni behagar', Akt II)",
"Custom Theme": "Anpassat tema",
"Theme Name": "Temanamn",
"Text Color": "Textfärg",
"Background Color": "Bakgrundsfärg",
"Link Color": "Länkfärg",
"Preview": "Förhandsgranska",
"Get Help from the Readest Community": "Få hjälp från Readest-communityn",
"A new version of Readest is available!": "En ny version av Readest finns!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} finns tillgänglig (installerad version {{currentVersion}}).",
"Download and install now?": "Ladda ner och installera nu?",
"Downloading {{downloaded}} of {{contentLength}}": "Laddar ner {{downloaded}} av {{contentLength}}",
"Download finished": "Nedladdning klar",
"DOWNLOAD & INSTALL": "LADDA NER & INSTALLERA",
"Changelog": "Ändringslogg",
"Software Update": "Programuppdatering",
"What's New in Readest": "Nyheter i Readest",
"Minimize": "Minimera",
"Maximize or Restore": "Maximera eller återställ",
"Select Files": "Välj filer",
"Select Image": "Välj bild",
"Select Video": "Välj video",
"Select Audio": "Välj ljud",
"Select Fonts": "Välj typsnitt",
"Storage": "Lagring",
"{{percentage}}% of Cloud Sync Space Used.": "{{percentage}}% av molnsynkutrymme använt.",
"Translation Characters": "Översättningstecken",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% av dagliga översättningstecken använt.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Daglig översättningskvot nådd. Uppgradera din plan för att fortsätta använda AI-översättningar.",
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Böcker",
"Open Library": "Open Library",
"Azure Translator": "Azure Translator",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Yandex Translate": "Yandex Translate",
"Gray": "Grå",
"Sepia": "Sepia",
"Grass": "Gräs",
"Cherry": "Körsbär",
"Sky": "Himmel",
"Solarized": "Solarized",
"Gruvbox": "Gruvbox",
"Nord": "Nord",
"Contrast": "Kontrast",
"Sunset": "Solnedgång",
"Reveal in Finder": "Visa i Finder",
"Reveal in File Explorer": "Visa i Utforskaren",
"Reveal in Folder": "Visa i mapp",
"Screen Brightness": "Skärmens ljusstyrka",
"Background Image": "Bakgrundsbild",
"Import Image": "Importera bild",
"Opacity": "Opacitet",
"Size": "Storlek",
"Cover": "Omslag",
"Contain": "Innehålla"
}
@@ -563,5 +563,48 @@
"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": "உரை வாசிப்பு",
"Screen Brightness": "திரை பிரகாசம்",
"Background Image": "பின்னணி படம்",
"Import Image": "புகைப்படம் இறக்குமதி",
"Opacity": "பார்வை",
"Size": "அளவு",
"Cover": "மூடு",
"Contain": "அடங்கும்"
}
@@ -559,5 +559,48 @@
"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": "อ่านออกเสียง",
"Screen Brightness": "ความสว่างหน้าจอ",
"Background Image": "ภาพพื้นหลัง",
"Import Image": "นำเข้าภาพ",
"Opacity": "ความโปร่งใส",
"Size": "ขนาด",
"Cover": "ปก",
"Contain": "บรรจุ"
}
@@ -563,5 +563,48 @@
"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",
"Screen Brightness": "Ekran Parlaklığı",
"Background Image": "Arka Plan Görüntüsü",
"Import Image": "Görüntü İçe Aktar",
"Opacity": "Opaklık",
"Size": "Boyut",
"Cover": "Kapak",
"Contain": "İçerir"
}
@@ -571,5 +571,48 @@
"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": "Читати вголос",
"Screen Brightness": "Яскравість екрану",
"Background Image": "Фонове зображення",
"Import Image": "Імпортувати зображення",
"Opacity": "Непрозорість",
"Size": "Розмір",
"Cover": "Обкладинка",
"Contain": "Вміщати"
}
@@ -559,5 +559,48 @@
"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",
"Screen Brightness": "Độ sáng màn hình",
"Background Image": "Ảnh nền",
"Import Image": "Nhập ảnh",
"Opacity": "Độ mờ",
"Size": "Kích thước",
"Cover": "Bìa",
"Contain": "Chứa"
}
@@ -559,5 +559,48 @@
"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": "语音朗读",
"Screen Brightness": "屏幕亮度",
"Background Image": "背景图片",
"Import Image": "导入图片",
"Opacity": "不透明度",
"Size": "大小",
"Cover": "封面",
"Contain": "包含"
}
@@ -559,5 +559,48 @@
"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": "朗讀",
"Screen Brightness": "螢幕亮度",
"Background Image": "背景圖片",
"Import Image": "匯入圖片",
"Opacity": "不透明度",
"Size": "大小",
"Cover": "封面",
"Contain": "包含"
}
+27
View File
@@ -1,5 +1,32 @@
{
"releases": {
"0.9.85": {
"date": "2025-10-14",
"notes": [
"TTS: Improved media session control compatibility across more Android systems",
"TTS: Made the TTS icon less distracting during read-aloud mode",
"Settings: Added an option to adjust screen brightness while reading",
"Settings: Added support for custom background images",
"PDF: Fixed an issue where scrolling and panning didnt work correctly when zoomed in",
"Android: Added adaptive launcher icon for better system integration",
"Reader: Added keyboard shortcuts for toggling bookmarks",
"Reader: Annotations are now rendered instantly when opening files"
]
},
"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,23 @@
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service
android:name="com.readest.native_tts.MediaPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -0,0 +1,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
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
@@ -62,12 +69,21 @@ class LockScreenOrientationRequestArgs {
var orientation: String? = null
}
@InvokeArg
class SetScreenBrightnessRequestArgs {
var brightness: Double? = null // 0.0 to 1.0
}
interface KeyDownInterceptor {
fun interceptVolumeKeys(enabled: Boolean)
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 +91,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 +419,91 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
@Command
fun get_screen_brightness(invoke: Invoke) {
val ret = JSObject()
try {
val brightness = Settings.System.getInt(
activity.contentResolver,
Settings.System.SCREEN_BRIGHTNESS
)
val normalizedBrightness = brightness / 255.0
ret.put("brightness", normalizedBrightness)
} catch (e: Exception) {
ret.put("error", e.message)
ret.put("brightness", -1.0)
}
invoke.resolve(ret)
}
@Command
fun set_screen_brightness(invoke: Invoke) {
val args = invoke.parseArgs(SetScreenBrightnessRequestArgs::class.java)
val ret = JSObject()
try {
val brightness = (args.brightness ?: 0.5).toFloat()
if (brightness < 0.0 || brightness > 1.0) {
invoke.reject("Brightness must be between 0.0 and 1.0")
return
}
val layoutParams = activity.window.attributes
layoutParams.screenBrightness = brightness
activity.window.attributes = layoutParams
ret.put("success", true)
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
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,11 @@ const COMMANDS: &[&str] = &[
"iap_restore_purchases",
"get_system_color_scheme",
"get_safe_area_insets",
"get_screen_brightness",
"set_screen_brightness",
"request_manage_storage_permission",
"checkPermissions",
"requestPermissions",
];
fn main() {
@@ -49,6 +49,10 @@ class LockScreenOrientationRequestArgs: Decodable {
let orientation: String?
}
class SetScreenBrightnessRequestArgs: Decodable {
let brightness: Float?
}
struct InitializeRequest: Decodable {
let publicKey: String?
}
@@ -539,6 +543,28 @@ class NativeBridgePlugin: Plugin {
let colorScheme = (userInterfaceStyle == .dark) ? "dark" : "light"
invoke.resolve(["colorScheme": colorScheme])
}
@objc public func get_screen_brightness(_ invoke: Invoke) {
let brightness = UIScreen.main.brightness
invoke.resolve(["brightness": brightness])
}
@objc public func set_screen_brightness(_ invoke: Invoke) {
guard let args = try? invoke.parseArgs(SetScreenBrightnessRequestArgs.self) else {
return invoke.reject("Failed to parse arguments")
}
let brightness = args.brightness ?? 0.5
if brightness < 0.0 || brightness > 1.0 {
return invoke.reject("Brightness must be between 0.0 and 1.0")
}
DispatchQueue.main.async {
UIScreen.main.brightness = CGFloat(brightness)
}
invoke.resolve(["success": true])
}
}
@_cdecl("init_plugin_native_bridge")
@@ -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-get-screen-brightness"
description = "Enables the get_screen_brightness command without any pre-configured scope."
commands.allow = ["get_screen_brightness"]
[[permission]]
identifier = "deny-get-screen-brightness"
description = "Denies the get_screen_brightness command without any pre-configured scope."
commands.deny = ["get_screen_brightness"]
@@ -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"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-screen-brightness"
description = "Enables the set_screen_brightness command without any pre-configured scope."
commands.allow = ["set_screen_brightness"]
[[permission]]
identifier = "deny-set-screen-brightness"
description = "Denies the set_screen_brightness command without any pre-configured scope."
commands.deny = ["set_screen_brightness"]
@@ -20,6 +20,11 @@ Default permissions for the plugin
- `allow-iap-restore-purchases`
- `allow-get-system-color-scheme`
- `allow-get-safe-area-insets`
- `allow-get-screen-brightness`
- `allow-set-screen-brightness`
- `allow-request-manage-storage-permission`
- `allow-checkPermissions`
- `allow-requestPermissions`
## Permission Table
@@ -85,6 +90,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>
@@ -137,6 +168,32 @@ Denies the get_safe_area_insets command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-screen-brightness`
</td>
<td>
Enables the get_screen_brightness command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-screen-brightness`
</td>
<td>
Denies the get_screen_brightness command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-get-status-bar-height`
</td>
@@ -397,6 +454,84 @@ 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-screen-brightness`
</td>
<td>
Enables the set_screen_brightness command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-set-screen-brightness`
</td>
<td>
Denies the set_screen_brightness command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-set-system-ui-visibility`
</td>
@@ -17,4 +17,9 @@ permissions = [
"allow-iap-restore-purchases",
"allow-get-system-color-scheme",
"allow-get-safe-area-insets",
"allow-get-screen-brightness",
"allow-set-screen-brightness",
"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",
@@ -342,6 +354,18 @@
"const": "deny-get-safe-area-insets",
"markdownDescription": "Denies the get_safe_area_insets command without any pre-configured scope."
},
{
"description": "Enables the get_screen_brightness command without any pre-configured scope.",
"type": "string",
"const": "allow-get-screen-brightness",
"markdownDescription": "Enables the get_screen_brightness command without any pre-configured scope."
},
{
"description": "Denies the get_screen_brightness command without any pre-configured scope.",
"type": "string",
"const": "deny-get-screen-brightness",
"markdownDescription": "Denies the get_screen_brightness command without any pre-configured scope."
},
{
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
"type": "string",
@@ -462,6 +486,42 @@
"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_screen_brightness command without any pre-configured scope.",
"type": "string",
"const": "allow-set-screen-brightness",
"markdownDescription": "Enables the set_screen_brightness command without any pre-configured scope."
},
{
"description": "Denies the set_screen_brightness command without any pre-configured scope.",
"type": "string",
"const": "deny-set-screen-brightness",
"markdownDescription": "Denies the set_screen_brightness command without any pre-configured scope."
},
{
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
@@ -487,10 +547,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-get-screen-brightness`\n- `allow-set-screen-brightness`\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-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-request-manage-storage-permission`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}
@@ -126,3 +126,25 @@ pub(crate) async fn get_safe_area_insets<R: Runtime>(
) -> Result<GetSafeAreaInsetsResponse> {
app.native_bridge().get_safe_area_insets()
}
#[command]
pub(crate) async fn get_screen_brightness<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetScreenBrightnessResponse> {
app.native_bridge().get_screen_brightness()
}
#[command]
pub(crate) async fn set_screen_brightness<R: Runtime>(
app: AppHandle<R>,
payload: SetScreenBrightnessRequest,
) -> Result<SetScreenBrightnessResponse> {
app.native_bridge().set_screen_brightness(payload)
}
#[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,21 @@ impl<R: Runtime> NativeBridge<R> {
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_screen_brightness(&self) -> crate::Result<GetScreenBrightnessResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_screen_brightness(
&self,
_payload: SetScreenBrightnessRequest,
) -> crate::Result<SetScreenBrightnessResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn request_manage_storage_permission(
&self,
) -> crate::Result<RequestManageStoragePermissionResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -53,6 +53,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::iap_restore_purchases,
commands::get_system_color_scheme,
commands::get_safe_area_insets,
commands::get_screen_brightness,
commands::set_screen_brightness,
commands::request_manage_storage_permission,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -169,3 +169,32 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_screen_brightness(&self) -> crate::Result<GetScreenBrightnessResponse> {
self.0
.run_mobile_plugin("get_screen_brightness", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn set_screen_brightness(
&self,
payload: SetScreenBrightnessRequest,
) -> crate::Result<SetScreenBrightnessResponse> {
self.0
.run_mobile_plugin("set_screen_brightness", payload)
.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,28 @@ pub struct GetSafeAreaInsetsResponse {
pub left: f64,
pub right: f64,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetScreenBrightnessResponse {
pub brightness: f64,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetScreenBrightnessRequest {
pub brightness: f64, // 0.0 to 1.0
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetScreenBrightnessResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestManageStoragePermissionResponse {
pub manage_storage: String, // "granted", "denied", or "prompt"
}
@@ -33,9 +33,10 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.media:media:1.7.1")
implementation("androidx.media3:media3-exoplayer:1.3.1")
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")
@@ -0,0 +1,232 @@
package com.readest.native_tts
import com.readest.native_tts.R
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.graphics.Bitmap
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.session.MediaButtonReceiver
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import app.tauri.plugin.JSObject
class MediaPlaybackService : MediaBrowserServiceCompat() {
private var mediaSession: MediaSessionCompat? = null
private lateinit var player: ExoPlayer
private lateinit var stateBuilder: PlaybackStateCompat.Builder
private lateinit var audioManager: AudioManager
private val afChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
Log.i("MediaPlaybackService", "Audio focus changed: $focusChange, $player.isPlaying")
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
player.volume = 1.0f
if (!player.isPlaying) player.play()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
player.volume = 0.3f
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (player.isPlaying) player.pause()
}
}
}
companion object {
private const val CHANNEL_ID = "media2_playback_channel"
private const val NOTIFICATION_ID = 1002
private const val MEDIA_ROOT_ID = "media_root_id"
var pluginEventTrigger: ((String, JSObject) -> Unit)? = null
var currentTitle: String = "Read Aloud"
var currentArtist: String = "Reading your content"
var currentArtwork: Bitmap? = null
}
override fun onCreate() {
super.onCreate()
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val result = audioManager.requestAudioFocus(
afChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.d("MediaPlaybackService", "Audio focus granted")
} else {
Log.w("MediaPlaybackService", "Failed to gain audio focus")
}
player = ExoPlayer.Builder(this).build()
mediaSession = MediaSessionCompat(baseContext, "ReadestMediaSession").apply {
stateBuilder = PlaybackStateCompat.Builder().setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
setPlaybackState(stateBuilder.build())
setCallback(SessionCallback())
setSessionToken(sessionToken)
isActive = true
}
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
updatePlaybackState()
}
override fun onPlaybackStateChanged(playbackState: Int) {
updatePlaybackState()
}
})
val mediaItem = MediaItem.fromUri("asset:///silence.mp3")
player.setMediaItem(mediaItem)
player.repeatMode = Player.REPEAT_MODE_ONE
player.prepare()
player.playWhenReady = true
showNotification(PlaybackStateCompat.STATE_PLAYING)
}
private inner class SessionCallback : MediaSessionCompat.Callback() {
override fun onPlay() {
player.play()
pluginEventTrigger?.invoke("media-session-play", JSObject())
updatePlaybackState()
}
override fun onPause() {
player.pause()
pluginEventTrigger?.invoke("media-session-pause", JSObject())
updatePlaybackState()
}
override fun onSkipToNext() {
player.seekTo(0)
pluginEventTrigger?.invoke("media-session-next", JSObject())
}
override fun onSkipToPrevious() {
player.seekTo(0)
pluginEventTrigger?.invoke("media-session-previous", JSObject())
}
}
private fun updatePlaybackState() {
val state = if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED
mediaSession?.setPlaybackState(
stateBuilder.setState(state, player.currentPosition, 1f).build()
)
showNotification(state)
}
private fun showNotification(playbackState: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, "Media Controls", NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
startForeground(NOTIFICATION_ID, buildNotification(playbackState))
}
private fun buildNotification(playbackState: Int): Notification {
val builder = NotificationCompat.Builder(this, CHANNEL_ID).apply {
setContentTitle(currentTitle)
setContentText(currentArtist)
setLargeIcon(currentArtwork)
setContentIntent(mediaSession!!.controller.sessionActivity)
setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this@MediaPlaybackService, PlaybackStateCompat.ACTION_STOP))
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
setSmallIcon(R.drawable.notification_icon)
setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSession?.sessionToken)
.setShowActionsInCompactView(0)
)
}
return builder.build()
}
override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? {
return BrowserRoot(MEDIA_ROOT_ID, null)
}
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.sendResult(null)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
MediaButtonReceiver.handleIntent(mediaSession, intent)
if (intent?.action == "UPDATE_METADATA") {
currentTitle = intent.getStringExtra("title") ?: currentTitle
currentArtist = intent.getStringExtra("artist") ?: currentArtist
val newArtwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("artwork", Bitmap::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("artwork")
}
if (newArtwork != null) {
currentArtwork = newArtwork
}
val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist)
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentArtwork)
mediaSession?.setMetadata(metadataBuilder.build())
showNotification(if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED)
} else if (intent?.action == "UPDATE_PLAYBACK_STATE") {
val isPlaying = intent.getBooleanExtra("playing", false)
val position = intent.getLongExtra("position", 0L) // in milliseconds
val duration = intent.getLongExtra("duration", 0L) // in milliseconds
if (isPlaying && !player.isPlaying) {
player.play()
} else if (!isPlaying && player.isPlaying) {
player.pause()
}
player.seekTo(position)
val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED
mediaSession?.setPlaybackState(
stateBuilder.setState(state, position, 1f).build()
)
showNotification(state)
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
player.release()
mediaSession?.release()
}
}
@@ -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,12 @@ 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.core.content.ContextCompat
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 +30,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 +75,45 @@ 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: Int? = null // in milliseconds
var duration: Int? = null // in milliseconds
}
@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
}
@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
@@ -77,9 +125,8 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
private val eventChannels = ConcurrentHashMap<String, Channel<TTSMessageEvent>>()
private val speakingJobs = ConcurrentHashMap<String, Job>()
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@Command
fun init(invoke: Invoke) {
coroutineScope.launch {
@@ -373,13 +420,121 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
invoke.reject("Exception getting voices: ${e.message}")
}
}
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
}
}
}
@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 ?: ""
coroutineScope.launch {
try {
val artworkBitmap = args.artwork?.let { loadArtworkFromUrl(it) }
val intent = Intent(activity, MediaPlaybackService::class.java).apply {
action = "UPDATE_METADATA"
putExtra("title", title)
putExtra("artist", artist)
putExtra("album", album)
putExtra("artwork", artworkBitmap)
}
activity.startService(intent)
invoke.resolve()
} catch (e: Exception) {
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
val duration = args.duration ?: 0
try {
val intent = Intent(activity, MediaPlaybackService::class.java).apply {
action = "UPDATE_PLAYBACK_STATE"
putExtra("playing", isPlaying)
putExtra("position", position)
putExtra("duration", duration)
}
activity.startService(intent)
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
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 {
val intent = Intent(activity, MediaPlaybackService::class.java)
if (active) {
MediaPlaybackService.pluginEventTrigger = { event, data -> trigger(event, data) }
MediaPlaybackService.currentTitle = FOREGROUND_SERVICE_TITLE
MediaPlaybackService.currentArtist = FOREGROUND_SERVICE_TEXT
ContextCompat.startForegroundService(activity, intent)
} else {
activity.stopService(intent)
MediaPlaybackService.pluginEventTrigger = null
}
invoke.resolve()
} catch (e: Exception) {
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 {
val intent = Intent(activity, MediaPlaybackService::class.java)
activity.stopService(intent)
coroutineScope.cancel()
textToSpeech?.shutdown()
eventChannels.values.forEach { it.close() }
eventChannels.clear()
speakingJobs.values.forEach { it.cancel() }
speakingJobs.clear()
Log.d(TAG, "Plugin destroyed successfully")
} catch (e: Exception) {
Log.e(TAG, "Error during plugin destruction", e)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -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]);
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
@@ -14,7 +14,8 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
import { formatAuthors, formatTitle } from '@/utils/book';
import { createBookFilter, createBookSorter } from '../utils/libraryUtils';
import { formatTitle } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { isMd5 } from '@/utils/md5';
@@ -75,7 +76,18 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const allBookshelfItems = generateBookshelfItems(libraryBooks);
const bookFilter = useMemo(() => createBookFilter(queryTerm), [queryTerm]);
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
const bookSorter = useMemo(() => createBookSorter(sortBy, uiLanguage), [sortBy, uiLanguage]);
const filteredBooks = useMemo(() => {
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks;
}, [libraryBooks, queryTerm, bookFilter]);
const allBookshelfItems = useMemo(() => {
return generateBookshelfItems(filteredBooks);
}, [filteredBooks]);
const autofocusRef = useAutoFocus<HTMLDivElement>();
@@ -104,8 +116,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
} else {
setCurrentBookshelf(allBookshelfItems);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryBooks, navBooksGroup]);
}, [allBookshelfItems, navBooksGroup, setCurrentBookshelf]);
useEffect(() => {
const group = searchParams?.get('group') || '';
@@ -165,13 +176,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
}, [router, settings, searchParams, allBookshelfItems, showGroupingModal]);
const toggleSelection = useCallback((id: string) => {
toggleSelectedBook(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggleSelection = useCallback(
(id: string) => {
toggleSelectedBook(id);
},
[toggleSelectedBook],
);
const openSelectedBooks = () => {
handleSetSelectMode(false);
@@ -195,7 +207,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const getBooksToDelete = () => {
const booksToDelete: Book[] = [];
bookIdsToDelete.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
for (const book of filteredBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (book && !book.deletedAt) {
booksToDelete.push(book);
}
@@ -231,50 +243,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowDeleteAlert(true);
};
const bookFilter = (item: Book, queryTerm: string) => {
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return (
searchTerm.test(title) ||
searchTerm.test(authors) ||
searchTerm.test(item.format) ||
(item.groupName && searchTerm.test(item.groupName)) ||
(item.metadata?.description && searchTerm.test(item.metadata?.description))
);
};
const bookSorter = (a: Book, b: Book) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
switch (sortBy) {
case 'title':
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case 'created':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const filteredBookshelfItems = currentBookshelfItems
.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
return true;
})
.sort((a, b) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
const sortedBookshelfItems = useMemo(() => {
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
return currentBookshelfItems.sort((a, b) => {
if (sortBy === 'updated') {
return (
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
@@ -289,14 +261,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
return 0;
}
});
}, [sortOrder, sortBy, uiLanguage, navBooksGroup, allBookshelfItems, bookSorter]);
useEffect(() => {
if (isSelectMode) {
setShowSelectModeActions(true);
if (isSelectAll) {
setSelectedBooks(
filteredBookshelfItems.map((item) => ('hash' in item ? item.hash : item.id)),
);
setSelectedBooks(filteredBooks.map((item) => item.hash));
} else if (isSelectNone) {
setSelectedBooks([]);
}
@@ -305,7 +276,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowSelectModeActions(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSelectMode, isSelectAll, isSelectNone]);
}, [isSelectMode, isSelectAll, isSelectNone, filteredBooks]);
useEffect(() => {
eventDispatcher.on('delete-books', handleDeleteBooksIntent);
@@ -330,7 +301,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
role='main'
aria-label={_('Bookshelf')}
>
{filteredBookshelfItems.map((item) => (
{sortedBookshelfItems.map((item) => (
<BookshelfItem
key={`library-item-${'hash' in item ? item.hash : item.id}`}
item={item}
@@ -353,16 +324,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',
@@ -109,7 +109,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
windowButtonVisible ? 'sm:pr-4' : 'sm:pr-6',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
@@ -150,7 +150,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
'input rounded-badge bg-base-300/45 h-9 w-full pl-10 pr-10 sm:h-7',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
@@ -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}
+22 -4
View File
@@ -38,21 +38,24 @@ import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation } from '@/utils/bridge';
import {
tauriHandleClose,
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
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 +79,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { selectFiles } = useFileSelector(appService, _);
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isFontLayoutSettingsDialogOpen } = useSettingsStore();
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -88,6 +93,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);
@@ -112,11 +118,22 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
await tauriHandleToggleFullScreen();
}
},
onCloseWindow: async () => {
if (isTauriAppPlatform()) {
await tauriHandleClose();
}
},
onQuitApp: async () => {
if (isTauriAppPlatform()) {
await tauriQuitApp();
}
},
onOpenFontLayoutSettings: () => {
setFontLayoutSettingsDialogOpen(true);
},
onOpenBooks: () => {
handleImportBooks();
},
});
useEffect(() => {
@@ -629,8 +646,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 +745,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
)}
<AboutWindow />
<UpdaterWindow />
<MigrateDataWindow />
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={''} />}
<Toast />
</div>
);
@@ -0,0 +1,38 @@
import { Book } from '@/types/book';
import { formatAuthors, formatTitle } from '@/utils/book';
export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
if (!queryTerm) return true;
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return (
searchTerm.test(title) ||
searchTerm.test(authors) ||
searchTerm.test(item.format) ||
(item.groupName && searchTerm.test(item.groupName)) ||
(item.metadata?.description && searchTerm.test(item.metadata?.description))
);
};
export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book, b: Book) => {
switch (sortBy) {
case 'title':
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case 'created':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};

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