Compare commits

...

44 Commits

Author SHA1 Message Date
Huang Xin f0c249bce2 release: version 0.9.76 (#1932) 2025-08-30 06:19:01 +02:00
Huang Xin e8a96fba95 compat: more compat with window insets on Android (#1931) 2025-08-30 06:18:06 +02:00
Huang Xin 92b01c40a0 layout: fix grouping modal not shown on Linux (#1930) 2025-08-30 05:28:02 +02:00
Huang Xin 6003eeadba compat: support more kosync server implementations, closes #1862 (#1929) 2025-08-30 04:54:31 +02:00
Huang Xin 856ed4d3b3 ux: add splash screen for iOS and Android and reduce flash when opening books (#1926) 2025-08-29 19:10:09 +02:00
Huang Xin 267fe58a8c fix: reduce screen flash with different background colors when app starts, closes #1915 (#1922) 2025-08-28 20:02:53 +02:00
Huang Xin 1b00d8c41c fix: support importing azw3 files on Android, closes #1920 (#1921) 2025-08-28 16:11:30 +02:00
Huang Xin cb126613b1 perf(sync): incremental sync of library books (#1917) 2025-08-27 19:04:57 +02:00
Huang Xin 515bfee2a1 fix(kosync): normalize xpointer to improve KOReader sync tolerance, closes #1857 (#1914) 2025-08-27 09:18:32 +02:00
Huang Xin 584f3511f8 refactor(kosync): move kosync into reader sidebar menu and add manual pull/push trigger (#1912) 2025-08-27 13:32:00 +08:00
Huang Xin 3ea54e6725 fix: display hidden footnotes, closes #1847 (#1907) 2025-08-26 12:45:12 +02:00
Huang Xin 48212d892f fonts: purge custom fonts when reseting fonts config (#1906) 2025-08-26 11:23:34 +02:00
Huang Xin 977c61b914 refactor: use useFileSelector hook for all file selection (#1905) 2025-08-26 10:04:10 +02:00
Huang Xin 66fceb2313 chore: add 10MB of grace bytes for storage quota (#1904) 2025-08-26 07:28:15 +02:00
Huang Xin 76feefff00 layout: fix overflow of some font names in custom fonts, closes #1901 (#1903) 2025-08-26 06:35:43 +02:00
Huang Xin 1be43ff704 fix: close reader window when trying to go to library in separate reader window, closes #1899 (#1900) 2025-08-25 18:01:06 +02:00
Huang Xin b6d4a547e5 chore: fix typo (#1898) 2025-08-25 09:41:36 +02:00
Huang Xin 5ee860f1e8 refactor: use safe insets from native for Android, closes #1793 and closes #1886 (#1897) 2025-08-25 09:38:49 +02:00
dependabot[bot] 0b01132d88 chore(deps): bump actions/setup-java in the github-actions group (#1896)
Bumps the github-actions group with 1 update: [actions/setup-java](https://github.com/actions/setup-java).


Updates `actions/setup-java` from 4 to 5
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-25 09:13:58 +02:00
Huang Xin b98c487919 theme: fix themed background color for PDFs on macOS, closes #1887 (#1894) 2025-08-24 08:29:45 +02:00
Huang Xin b43b08d423 fix: init traffic light properly in library page on macOS, closes #1872 (#1891) 2025-08-24 06:33:52 +02:00
Huang Xin 3ddb6e6422 fix: handle some cases where footnote is empty, closes #1880 (#1883) 2025-08-23 10:28:51 +02:00
Huang Xin ea9146be5b fonts: parse font family as well as font style when importing fonts, closes #1876 (#1881) 2025-08-23 10:27:58 +02:00
Huang Xin 78cb1f45e4 chore: fix workflow dependency for release (#1874) 2025-08-22 17:51:01 +02:00
Huang Xin e472d1cf1e release: version 0.9.75 (#1873) 2025-08-22 17:47:24 +02:00
shaqtiktok 75dc04d598 fix(tts): reuse audio object in the same paragraph for better performance, closes #1777 (#1853)
* Remove stopInternal which causes audio delaying

* Change audio obj to not be recreated every sentence

* refactor abort signal handler

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-08-22 17:22:34 +02:00
Huang Xin fc4c033cf3 layout: layout tweaks on the custom fonts panel (#1871) 2025-08-22 12:35:20 +02:00
Huang Xin dbee054838 feat: apply custom fonts directly in custom font panel (#1870) 2025-08-22 12:17:57 +02:00
Huang Xin 45b805dcd4 layout: keep book title align center of the blank space when there are window buttons, closes #1867 (#1869) 2025-08-22 10:42:34 +02:00
Huang Xin a8ac54b51c layout: fix position of close button miss-match between reader and library page on Windows and Linux, closes #1866 (#1868) 2025-08-22 10:05:00 +02:00
German Shein 86f705eae9 feat: support author sorting (last name first), closes #1799 (#1865)
* [Issue #1799](https://github.com/readest/readest/issues/1799): Changing the name order when sorting the book by Author by placing the last name first in Arabic, Tibetan, German, English, Spanish, French, Hindi, Italian, Dutch, Polish, Portuguese, Russian, Thai, Turkish and Ukrainian languages

* refactor author sort

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-08-22 07:59:59 +02:00
Huang Xin d390209dab feat: support importing TTF/ODF fonts, closes #979 and closes #1708 (#1864) 2025-08-21 21:49:08 +02:00
Huang Xin 149f94be4c css: ensure workarounds don’t interfere with body background color (#1859) 2025-08-20 18:30:20 +02:00
Huang Xin ef1a1eab98 layout: consistent label font weight for settings panels (#1856) 2025-08-20 12:14:45 +02:00
lijiahao66666 dc7c6e14bc fix(translator): refresh translation when provider is changed in translator popup (#1855)
– Add the translate function to the dependency array in the TranslatorPopup component
– This change ensures that the translation content is re-fetched whenever the translate function is updated

Co-authored-by: 李家豪 <7904127+li_jiahaojj@user.noreply.gitee.com>
2025-08-20 11:54:20 +02:00
Huang Xin 8c31dc85f9 chore: bump next.js, opennext, wrangler and supabase to latest versions (#1851) 2025-08-20 05:57:28 +02:00
Huang Xin 7b8d1ddc35 chore: bump tauri to latest dev branch (#1849) 2025-08-20 05:15:44 +02:00
Huang Xin 8c12399732 layout: fix footer bar layout in landscape mode on Android, closes #1803 (#1843) 2025-08-19 15:49:38 +02:00
Huang Xin 93a4b1a448 css: fix unexpected align center for some EPUBs (#1842) 2025-08-19 15:05:26 +02:00
Huang Xin 04b0752b22 layout: fix drop down in TTS panel cannot open on older iOS, closes #1810 (#1841) 2025-08-19 15:02:02 +02:00
Huang Xin 5137c08204 chore: more aggressive cache for CI (#1839) 2025-08-19 10:21:20 +02:00
Huang Xin ec0b44fb9f reader: fix collapse of nested TOC for some EPUBs, closes #1820 and closes #1813 (#1837) 2025-08-19 09:55:25 +02:00
Huang Xin 0bf83dfe67 css: use monospace font for code blocks when overriding custom book fonts, closes #1805 (#1836) 2025-08-19 07:56:10 +02:00
Huang Xin b151b70cb9 fix: don't proxy network connection from Tailscale IP addresses, closes #1834 (#1835) 2025-08-19 05:02:29 +02:00
130 changed files with 3513 additions and 1907 deletions
+4 -2
View File
@@ -57,8 +57,10 @@ jobs:
uses: actions/cache@v4
with:
path: apps/readest-app/.next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('apps/readest-app/package.json', 'pnpm-lock.yaml') }}
restore-keys: nextjs-${{ runner.os }}-
key: nextjs-${{ matrix.config.platform }}-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
nextjs-${{ matrix.config.platform }}-${{ github.sha }}-
nextjs-${{ matrix.config.platform }}-
- name: install Dependencies
working-directory: apps/readest-app
+35 -35
View File
@@ -48,6 +48,39 @@ jobs:
core.setOutput('release_version', version);
core.setOutput('release_note', releaseNote);
update-release:
permissions:
contents: write
runs-on: ubuntu-latest
needs: get-release
steps:
- name: update release
id: update-release
uses: actions/github-script@v7
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_tag: ${{ needs.get-release.outputs.release_tag }}
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const { data } = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: process.env.release_tag,
})
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: body,
draft: false,
prerelease: false
})
build-tauri:
needs: get-release
permissions:
@@ -109,7 +142,7 @@ jobs:
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: '17'
@@ -288,41 +321,8 @@ jobs:
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
update-release:
permissions:
contents: write
runs-on: ubuntu-latest
needs: [get-release, build-tauri]
steps:
- name: update release
id: update-release
uses: actions/github-script@v7
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_tag: ${{ needs.get-release.outputs.release_tag }}
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const { data } = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: process.env.release_tag,
})
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: body,
draft: false,
prerelease: false
})
upload-to-r2:
needs: [get-release, update-release]
needs: [get-release, build-tauri]
uses: ./.github/workflows/upload-to-r2.yml
with:
tag: ${{ needs.get-release.outputs.release_tag }}
+3
View File
@@ -40,3 +40,6 @@ next-env.d.ts
target
fastlane/report.xml
*.koplugin.zip
Generated
+374 -376
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -38,6 +38,7 @@ rust-version = "1.77.2"
tauri = { path = "packages/tauri/crates/tauri" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-os = { path = "packages/tauri-plugins/plugins/os" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
tauri-plugin-dialog = { path = "packages/tauri-plugins/plugins/dialog" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.72",
"version": "0.9.76",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -47,19 +47,19 @@
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.6.1",
"@opennextjs/cloudflare": "^1.6.5",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.50.2",
"@tauri-apps/api": "2.6.0",
"@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.0",
"@tauri-apps/plugin-dialog": "^2.3.0",
"@tauri-apps/plugin-fs": "^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-haptics": "^2.3.0",
"@tauri-apps/plugin-http": "^2.5.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",
@@ -84,7 +84,7 @@
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"marked": "^15.0.12",
"next": "15.3.3",
"next": "15.5.0",
"overlayscrollbars": "^2.11.4",
"overlayscrollbars-react": "^0.5.6",
"posthog-js": "^1.246.0",
@@ -105,7 +105,7 @@
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.7.0",
"@tauri-apps/cli": "2.8.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/cors": "^2.8.17",
@@ -137,6 +137,6 @@
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"wrangler": "^4.26.0"
"wrangler": "^4.31.0"
}
}
@@ -459,7 +459,6 @@
"Google Books": "كتب جوجل",
"Open Library": "المكتبة المفتوحة",
"Fiction, Science, History": "خيال، علوم، تاريخ",
"Select Cover Image": "اختر صورة الغلاف",
"Open Book in New Window": "فتح الكتاب في نافذة جديدة",
"Voices for {{lang}}": "الأصوات لـ {{lang}}",
"Yandex Translate": "ترجمة Yandex",
@@ -500,7 +499,6 @@
"File Content (recommended)": "محتوى الملف (موصى به)",
"File Name": "اسم الملف",
"Device Name": "اسم الجهاز",
"Sync Tolerance": "تحمل المزامنة",
"Connect to your KOReader Sync server.": "الاتصال بخادم مزامنة KOReader الخاص بك.",
"Server URL": "عنوان URL للخادم",
"Username": "اسم المستخدم",
@@ -519,6 +517,20 @@
"Approximately {{percentage}}%": "تقريبًا {{percentage}}%",
"Failed to connect": "فشل في الاتصال",
"Sync Server Connected": "تم الاتصال بخادم المزامنة",
"Precision: {{precision}} digits after the decimal": "الدقة: {{precision}} أرقام بعد الفاصلة",
"Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}"
"Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}",
"Custom Fonts": "الخطوط المخصصة",
"Cancel Delete": "إلغاء الحذف",
"Import Font": "استيراد خط",
"Delete Font": "حذف الخط",
"Tips": "نصائح",
"Custom fonts can be selected from the Font Face menu": "يمكن اختيار الخطوط المخصصة من قائمة نوع الخط",
"Manage Custom Fonts": "إدارة الخطوط المخصصة",
"Select Files": "حدد الملفات",
"Select Image": "حدد صورة",
"Select Video": "حدد فيديو",
"Select Audio": "حدد صوت",
"Select Fonts": "حدد الخطوط",
"Supported font formats: .ttf, .otf, .woff, .woff2": "تنسيقات الخط المدعومة: .ttf، .otf، .woff، .woff2",
"Push Progress": "تقدم الدفع",
"Pull Progress": "تقدم السحب"
}
@@ -438,7 +438,6 @@
"Google Books": "Google དཔེ་མཛོད།",
"Open Library": "སྒོ་འབྱེད་དཔེ་ཁང་།",
"Fiction, Science, History": "སྒྲུང་གཏམ། ཚན་རིག ལོ་རྒྱུས།",
"Select Cover Image": "སྟོང་ངོས་ཀྱི་པར་རིས་འདེམས་པ།",
"Open Book in New Window": "སྒེའུ་ཁུང་གསར་པའི་ནང་དཔེ་དེབ་ཁ་ཕྱེ་བ།",
"Voices for {{lang}}": "{{lang}} གྱི་སྒྲ་སྐད།",
"Yandex Translate": "Yandex སྒྱུར་བཅོས།",
@@ -482,8 +481,6 @@
"File Content (recommended)": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"File Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Device Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Sync Tolerance": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Precision: {{precision}} digits after the decimal": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"Connect to your KOReader Sync server.": "ཁྱེད་ཀྱིས KOReader སྤྲི་དོན་སང་བཞིན་འདེམས་པ།",
"Server URL": "སང་བཞིན་འདེམས་པའི URL",
"Username": "ཁྱེད་ཀྱིས་འབྱུང་ཁུངས་འདེམས་པ།",
@@ -498,7 +495,22 @@
"Remote Progress": "བརྒྱབ་འབྱུང་ཁུངས།",
"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": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Page {{page}} of {{total}} ({{percentage}}%)": "ཤོག་ {{page}} དང་ {{total}} ({{percentage}}%)",
"Current position": "ད་ལྟ་བཞིན་འདེམས་པ།",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ད་ལྟ་བཞིན་འདེམས་པ།",
"Approximately {{percentage}}%": "ད་ལྟ་བཞིན་འདེམས་པ།"
"Current position": "ད་ལྟའི་གནས་ས།",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ཕྲན་བུ་ཤོག་ངོས་ {{page}} / {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "ཕྲན་བུ་ {{percentage}}%",
"Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས།",
"Cancel Delete": "སུབ་པ་འདོར་བ།",
"Import Font": "ཡིག་གཟུགས་འདྲེན་འཇུག",
"Delete Font": "ཡིག་གཟུགས་སུབ་པ།",
"Tips": "གསལ་འདེབས།",
"Custom fonts can be selected from the Font Face menu": "རང་བཞིན་ཡིག་གཟུགས་ནི་ཡིག་གཟུགས་ངོ་བོའི་ཟ་འབྲིང་ནས་འདེམས་ཆོག",
"Manage Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས་འཛིན་སྐྱོང་།",
"Select Files": "ཡིག་ཆ་འདེམས་པ།",
"Select Image": "རི་མོ་འདེམས་པ།",
"Select Video": "བརྙན་ཟློས་འདེམས་པ།",
"Select Audio": "སྒྲ་ཟློས་འདེམས་པ།",
"Select Fonts": "ཡིག་གཟུགས་འདེམས་པ།",
"Supported font formats: .ttf, .otf, .woff, .woff2": "ཡིག་གཟུགས་འདེམས་པ་བྱས་ནས་བསྐྱར་བརྗེ་བ། ཡིག་གཟུགས་ཀྱི་སྒྲ་སྐད། .ttf, .otf, .woff, .woff2",
"Push Progress": "འདེགས་པའི་འཕེལ་རིམ།",
"Pull Progress": "འདྲུད་པའི་འཕེལ་རིམ།"
}
@@ -443,7 +443,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte",
"Select Cover Image": "Coverbild auswählen",
"Open Book in New Window": "Buch in neuem Fenster öffnen",
"Voices for {{lang}}": "Stimmen für {{lang}}",
"Yandex Translate": "Yandex Übersetzer",
@@ -484,7 +483,6 @@
"File Content (recommended)": "Dateiinhalte (empfohlen)",
"File Name": "Dateiname",
"Device Name": "Gerätename",
"Sync Tolerance": "Sync-Toleranz",
"Connect to your KOReader Sync server.": "Mit Ihrem KOReader Sync-Server verbinden.",
"Server URL": "Server-URL",
"Username": "Benutzername",
@@ -503,6 +501,20 @@
"Approximately {{percentage}}%": "Ungefähr {{percentage}}%",
"Failed to connect": "Verbindung fehlgeschlagen",
"Sync Server Connected": "Sync-Server verbunden",
"Precision: {{precision}} digits after the decimal": "Präzision: {{precision}} Nachkommastellen",
"Sync as {{userDisplayName}}": "Sync als {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Sync als {{userDisplayName}}",
"Custom Fonts": "Benutzerdefinierte Schriftarten",
"Cancel Delete": "Löschen abbrechen",
"Import Font": "Schriftart importieren",
"Delete Font": "Schriftart löschen",
"Tips": "Tipps",
"Custom fonts can be selected from the Font Face menu": "Benutzerdefinierte Schriftarten können aus dem Schriftart-Menü ausgewählt werden",
"Manage Custom Fonts": "Benutzerdefinierte Schriftarten verwalten",
"Select Files": "Dateien auswählen",
"Select Image": "Bild auswählen",
"Select Video": "Video auswählen",
"Select Audio": "Audio auswählen",
"Select Fonts": "Schriftarten auswählen",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Unterstützte Schriftartenformate: .ttf, .otf, .woff, .woff2",
"Push Progress": "Fortschritt senden",
"Pull Progress": "Fortschritt empfangen"
}
@@ -443,7 +443,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία",
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου",
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο",
"Voices for {{lang}}": "Φωνές για {{lang}}",
"Yandex Translate": "Μετάφραση Yandex",
@@ -484,7 +483,6 @@
"File Content (recommended)": "Περιεχόμενο αρχείου (συνιστάται)",
"File Name": "Όνομα αρχείου",
"Device Name": "Όνομα συσκευής",
"Sync Tolerance": "Ανοχή συγχρονισμού",
"Connect to your KOReader Sync server.": "Συνδεθείτε στον διακομιστή συγχρονισμού KOReader.",
"Server URL": "Διεύθυνση URL διακομιστή",
"Username": "Όνομα χρήστη",
@@ -503,6 +501,20 @@
"Approximately {{percentage}}%": "Περίπου {{percentage}}%",
"Failed to connect": "Αποτυχία σύνδεσης",
"Sync Server Connected": "Ο διακομιστής συγχρονισμού είναι συνδεδεμένος",
"Precision: {{precision}} digits after the decimal": "Ακρίβεια: {{precision}} ψηφία μετά την υποδιαστολή",
"Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}",
"Custom Fonts": "Προσαρμοσμένες Γραμματοσειρές",
"Cancel Delete": "Ακύρωση Διαγραφής",
"Import Font": "Εισαγωγή Γραμματοσειράς",
"Delete Font": "Διαγραφή Γραμματοσειράς",
"Tips": "Συμβουλές",
"Custom fonts can be selected from the Font Face menu": "Οι προσαρμοσμένες γραμματοσειρές μπορούν να επιλεγούν από το μενού Γραμματοσειράς",
"Manage Custom Fonts": "Διαχείριση Προσαρμοσμένων Γραμματοσειρών",
"Select Files": "Επιλογή Αρχείων",
"Select Image": "Επιλογή Εικόνας",
"Select Video": "Επιλογή Βίντεο",
"Select Audio": "Επιλογή Ήχου",
"Select Fonts": "Επιλογή Γραμματοσειρών",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Υποστηριζόμενες μορφές γραμματοσειρών: .ttf, .otf, .woff, .woff2",
"Push Progress": "Αποστολή προόδου",
"Pull Progress": "Λήψη προόδου"
}
@@ -88,7 +88,6 @@
"File Content (recommended)": "Contenido del archivo (recomendado)",
"File Name": "Nombre del archivo",
"Device Name": "Nombre del dispositivo",
"Sync Tolerance": "Tolerancia de sincronización",
"Reading Progress Synced": "Progreso de lectura sincronizado",
"Sync Conflict": "Conflicto de sincronización",
"Sync reading progress from \"{{deviceName}}\"?": "¿Quieres sincronizar el progreso de lectura desde el dispositivo \"{{deviceName}}\"?",
@@ -476,7 +475,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Ficción, Ciencia, Historia",
"Select Cover Image": "Seleccionar imagen de portada",
"Open Book in New Window": "Abre el libro en una nueva ventana",
"Voices for {{lang}}": "Voces para {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -507,6 +505,20 @@
"Remove from Device Only": "Eliminar solo del dispositivo",
"Failed to connect": "Error al conectar",
"Sync Server Connected": "Servidor de sincronización conectado",
"Precision: {{precision}} digits after the decimal": "Precisión: {{precision}} dígitos después del decimal",
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
"Custom Fonts": "Fuentes Personalizadas",
"Cancel Delete": "Cancelar Eliminación",
"Import Font": "Importar Fuente",
"Delete Font": "Eliminar Fuente",
"Tips": "Consejos",
"Custom fonts can be selected from the Font Face menu": "Las fuentes personalizadas se pueden seleccionar desde el menú de Tipografía",
"Manage Custom Fonts": "Administrar Fuentes Personalizadas",
"Select Files": "Seleccionar Archivos",
"Select Image": "Seleccionar Imagen",
"Select Video": "Seleccionar Video",
"Select Audio": "Seleccionar Audio",
"Select Fonts": "Seleccionar Fuentes",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formatos de fuente compatibles: .ttf, .otf, .woff, .woff2",
"Push Progress": "Empujar progreso",
"Pull Progress": "Tirar progreso"
}
@@ -447,7 +447,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fiction, Science, Histoire",
"Select Cover Image": "Sélectionner l'image de couverture",
"Open Book in New Window": "Ouvrir dans une nouvelle fenêtre",
"Voices for {{lang}}": "Voix pour {{lang}}",
"Yandex Translate": "Yandex Traduction",
@@ -488,7 +487,6 @@
"File Content (recommended)": "Contenu du fichier (recommandé)",
"File Name": "Nom du fichier",
"Device Name": "Nom de l'appareil",
"Sync Tolerance": "Tolérance de synchronisation",
"Connect to your KOReader Sync server.": "Connectez-vous à votre serveur de synchronisation KOReader.",
"Server URL": "URL du serveur",
"Username": "Nom d'utilisateur",
@@ -507,6 +505,20 @@
"Approximately {{percentage}}%": "Environ {{percentage}}%",
"Failed to connect": "Échec de la connexion",
"Sync Server Connected": "Serveur de synchronisation connecté",
"Precision: {{precision}} digits after the decimal": "Précision : {{precision}} chiffres après la virgule",
"Sync as {{userDisplayName}}": "Synchroniser en tant que {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Synchroniser en tant que {{userDisplayName}}",
"Custom Fonts": "Polices Personnalisées",
"Cancel Delete": "Annuler la Suppression",
"Import Font": "Importer une Police",
"Delete Font": "Supprimer la Police",
"Tips": "Conseils",
"Custom fonts can be selected from the Font Face menu": "Les polices personnalisées peuvent être sélectionnées depuis le menu Police",
"Manage Custom Fonts": "Gérer les Polices Personnalisées",
"Select Files": "Sélectionner des Fichiers",
"Select Image": "Sélectionner une Image",
"Select Video": "Sélectionner une Vidéo",
"Select Audio": "Sélectionner un Audio",
"Select Fonts": "Sélectionner des Polices",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formats de police pris en charge : .ttf, .otf, .woff, .woff2",
"Push Progress": "Pousser la Progression",
"Pull Progress": "Tirer la Progression"
}
@@ -443,7 +443,6 @@
"Google Books": "गूगल बुक्स",
"Open Library": "ओपन लाइब्रेरी",
"Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास",
"Select Cover Image": "कवर इमेज चुनें",
"Open Book in New Window": "नई विंडो में पुस्तक खोलें",
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें",
"Yandex Translate": "यांडेक्स अनुवाद",
@@ -484,7 +483,6 @@
"File Content (recommended)": "फाइल सामग्री (अनुशंसित)",
"File Name": "फाइल नाम",
"Device Name": "डिवाइस नाम",
"Sync Tolerance": "सिंक सहिष्णुता",
"Connect to your KOReader Sync server.": "अपने KOReader सिंक सर्वर से कनेक्ट करें।",
"Server URL": "सर्वर यूआरएल",
"Username": "उपयोगकर्ता नाम",
@@ -503,6 +501,20 @@
"Approximately {{percentage}}%": "लगभग {{percentage}}%",
"Failed to connect": "कनेक्ट करने में विफल",
"Sync Server Connected": "सिंक सर्वर कनेक्टेड",
"Precision: {{precision}} digits after the decimal": "सटीकता: {{precision}} दशमलव के बाद अंक",
"Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}"
"Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}",
"Custom Fonts": "कस्टम फ़ॉन्ट",
"Cancel Delete": "डिलीट रद्द करें",
"Import Font": "फ़ॉन्ट आयात करें",
"Delete Font": "फ़ॉन्ट डिलीट करें",
"Tips": "सुझाव",
"Custom fonts can be selected from the Font Face menu": "कस्टम फ़ॉन्ट को फ़ॉन्ट फेस मेनू से चुना जा सकता है",
"Manage Custom Fonts": "कस्टम फ़ॉन्ट प्रबंधन",
"Select Files": "फ़ाइलें चुनें",
"Select Image": "छवि चुनें",
"Select Video": "वीडियो चुनें",
"Select Audio": "ऑडियो चुनें",
"Select Fonts": "फ़ॉन्ट चुनें",
"Supported font formats: .ttf, .otf, .woff, .woff2": "समर्थित फ़ॉन्ट प्रारूप: .ttf, .otf, .woff, .woff2",
"Push progress": "पुश प्रगति",
"Pull progress": "पुल प्रगति"
}
@@ -439,7 +439,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fiksi, Sains, Sejarah",
"Select Cover Image": "Pilihan Gambar Sampul",
"Open Book in New Window": "Buka Buku di Jendela Baru",
"Voices for {{lang}}": "Suara untuk {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -480,7 +479,6 @@
"File Content (recommended)": "Konten File (disarankan)",
"File Name": "Nama File",
"Device Name": "Nama Perangkat",
"Sync Tolerance": "Toleransi Sinkronisasi",
"Connect to your KOReader Sync server.": "Hubungkan ke server Sinkronisasi KOReader Anda.",
"Server URL": "URL Server",
"Username": "Nama Pengguna",
@@ -499,6 +497,20 @@
"Approximately {{percentage}}%": "Sekitar {{percentage}}%",
"Failed to connect": "Gagal terhubung",
"Sync Server Connected": "Server sinkronisasi terhubung",
"Precision: {{precision}} digits after the decimal": "Presisi: {{precision}} digit setelah desimal",
"Sync as {{userDisplayName}}": "Sinkronkan sebagai {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Sinkronkan sebagai {{userDisplayName}}",
"Custom Fonts": "Font Kustom",
"Cancel Delete": "Batal Hapus",
"Import Font": "Impor Font",
"Delete Font": "Hapus Font",
"Tips": "Tips",
"Custom fonts can be selected from the Font Face menu": "Font kustom dapat dipilih dari menu Font Face",
"Manage Custom Fonts": "Kelola Font Kustom",
"Select Files": "Pilih File",
"Select Image": "Pilih Gambar",
"Select Video": "Pilih Video",
"Select Audio": "Pilih Audio",
"Select Fonts": "Pilih Font",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Format font yang didukung: .ttf, .otf, .woff, .woff2",
"Push Progress": "Dorong Progres",
"Pull Progress": "Tarik Progres"
}
@@ -447,7 +447,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Narrativa, Scienza, Storia",
"Select Cover Image": "Seleziona immagine di copertina",
"Open Book in New Window": "Apri libro in una nuova finestra",
"Voices for {{lang}}": "Voci per {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -488,7 +487,6 @@
"File Content (recommended)": "Contenuto del File (consigliato)",
"File Name": "Nome del File",
"Device Name": "Nome del Dispositivo",
"Sync Tolerance": "Tolleranza di Sincronizzazione",
"Connect to your KOReader Sync server.": "Connettiti al tuo server KOReader Sync.",
"Server URL": "URL del Server",
"Username": "Nome Utente",
@@ -507,6 +505,20 @@
"Approximately {{percentage}}%": "Circa {{percentage}}%",
"Failed to connect": "Impossibile connettersi",
"Sync Server Connected": "Server di sincronizzazione connesso",
"Precision: {{precision}} digits after the decimal": "Precisione: {{precision}} cifre dopo la virgola",
"Sync as {{userDisplayName}}": "Sincronizza come {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Sincronizza come {{userDisplayName}}",
"Custom Fonts": "Font Personalizzati",
"Cancel Delete": "Annulla Eliminazione",
"Import Font": "Importa Font",
"Delete Font": "Elimina Font",
"Tips": "Suggerimenti",
"Custom fonts can be selected from the Font Face menu": "I font personalizzati possono essere selezionati dal menu Carattere",
"Manage Custom Fonts": "Gestisci Font Personalizzati",
"Select Files": "Seleziona File",
"Select Image": "Seleziona Immagine",
"Select Video": "Seleziona Video",
"Select Audio": "Seleziona Audio",
"Select Fonts": "Seleziona Font",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formati di font supportati: .ttf, .otf, .woff, .woff2",
"Push progress": "Progresso push",
"Pull progress": "Progresso pull"
}
@@ -439,7 +439,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "小説、科学、歴史",
"Select Cover Image": "表紙画像を選択",
"Open Book in New Window": "新しいウィンドウで本を開く",
"Voices for {{lang}}": "{{lang}}の音声",
"Yandex Translate": "Yandex翻訳",
@@ -480,7 +479,6 @@
"File Content (recommended)": "ファイル内容(推奨)",
"File Name": "ファイル名",
"Device Name": "デバイス名",
"Sync Tolerance": "同期許容範囲",
"Connect to your KOReader Sync server.": "KOReader Syncサーバーに接続します。",
"Server URL": "サーバーURL",
"Username": "ユーザー名",
@@ -499,6 +497,20 @@
"Approximately {{percentage}}%": "おおよそ {{percentage}}%",
"Failed to connect": "接続に失敗しました",
"Sync Server Connected": "同期サーバーに接続されました",
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 桁の小数点以下",
"Sync as {{userDisplayName}}": "{{userDisplayName}} として同期"
"Sync as {{userDisplayName}}": "{{userDisplayName}} として同期",
"Custom Fonts": "カスタムフォント",
"Cancel Delete": "削除をキャンセル",
"Import Font": "フォントをインポート",
"Delete Font": "フォントを削除",
"Tips": "ヒント",
"Custom fonts can be selected from the Font Face menu": "カスタムフォントは書体メニューから選択できます",
"Manage Custom Fonts": "カスタムフォント管理",
"Select Files": "ファイルを選択",
"Select Image": "画像を選択",
"Select Video": "動画を選択",
"Select Audio": "音声を選択",
"Select Fonts": "フォントを選択",
"Supported font formats: .ttf, .otf, .woff, .woff2": "サポートされているフォント形式:.ttf, .otf, .woff, .woff2",
"Push Progress": "進捗をプッシュ",
"Pull Progress": "進捗をプル"
}
@@ -439,7 +439,6 @@
"Google Books": "구글 북스",
"Open Library": "오픈 라이브러리",
"Fiction, Science, History": "소설, 과학, 역사",
"Select Cover Image": "표지 이미지 선택",
"Open Book in New Window": "새 창에서 책 열기",
"Voices for {{lang}}": "{{lang}}의 음성",
"Yandex Translate": "Yandex 번역",
@@ -480,7 +479,6 @@
"File Content (recommended)": "파일 내용 (권장)",
"File Name": "파일 이름",
"Device Name": "장치 이름",
"Sync Tolerance": "동기화 허용 오차",
"Connect to your KOReader Sync server.": "KOReader Sync 서버에 연결합니다.",
"Server URL": "서버 URL",
"Username": "사용자 이름",
@@ -499,6 +497,20 @@
"Approximately {{percentage}}%": "대략 {{percentage}}%",
"Failed to connect": "연결 실패",
"Sync Server Connected": "동기화 서버에 연결됨",
"Precision: {{precision}} digits after the decimal": "정밀도: {{precision}} 자리의 소수점 이하",
"Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화"
"Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화",
"Custom Fonts": "사용자 정의 글꼴",
"Cancel Delete": "삭제 취소",
"Import Font": "글꼴 가져오기",
"Delete Font": "글꼴 삭제",
"Tips": "팁",
"Custom fonts can be selected from the Font Face menu": "사용자 정의 글꼴은 글꼴 메뉴에서 선택할 수 있습니다",
"Manage Custom Fonts": "사용자 정의 글꼴 관리",
"Select Files": "파일 선택",
"Select Image": "이미지 선택",
"Select Video": "동영상 선택",
"Select Audio": "오디오 선택",
"Select Fonts": "글꼴 선택",
"Supported font formats: .ttf, .otf, .woff, .woff2": "지원되는 글꼴 형식: .ttf, .otf, .woff, .woff2",
"Push Progress": "진행 상황 푸시",
"Pull Progress": "진행 상황 끌어오기"
}
@@ -443,7 +443,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis",
"Select Cover Image": "Selecteer omslagafbeelding",
"Open Book in New Window": "Open boek in nieuw venster",
"Voices for {{lang}}": "Stemmen voor {{lang}}",
"Yandex Translate": "Yandex Vertalen",
@@ -484,7 +483,6 @@
"File Content (recommended)": "Bestandsinhoud (aanbevolen)",
"File Name": "Bestandsnaam",
"Device Name": "Apparaatnaam",
"Sync Tolerance": "Synchronisatietolerantie",
"Connect to your KOReader Sync server.": "Verbind met je KOReader Sync-server.",
"Server URL": "Server-URL",
"Username": "Gebruikersnaam",
@@ -503,6 +501,20 @@
"Approximately {{percentage}}%": "Ongeveer {{percentage}}%",
"Failed to connect": "Verbinding mislukt",
"Sync Server Connected": "Synchronisatie server verbonden",
"Precision: {{precision}} digits after the decimal": "Precisie: {{precision}} cijfers na de komma",
"Sync as {{userDisplayName}}": "Synchroniseren als {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Synchroniseren als {{userDisplayName}}",
"Custom Fonts": "Aangepaste Lettertypen",
"Cancel Delete": "Verwijderen Annuleren",
"Import Font": "Lettertype Importeren",
"Delete Font": "Lettertype Verwijderen",
"Tips": "Tips",
"Custom fonts can be selected from the Font Face menu": "Aangepaste lettertypen kunnen worden geselecteerd vanuit het Lettertype menu",
"Manage Custom Fonts": "Aangepaste Lettertypen Beheren",
"Select Files": "Bestanden Selecteren",
"Select Image": "Afbeelding Selecteren",
"Select Video": "Video Selecteren",
"Select Audio": "Audio Selecteren",
"Select Fonts": "Lettertypen Selecteren",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Ondersteunde lettertypeformaten: .ttf, .otf, .woff, .woff2",
"Push Progress": "Voortgang pushen",
"Pull Progress": "Voortgang pullen"
}
@@ -451,7 +451,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Beletrystyka, Nauka, Historia",
"Select Cover Image": "Zaznacz obraz okładki",
"Open Book in New Window": "Otwórz książkę w nowym oknie",
"Voices for {{lang}}": "{{lang}} Głosy",
"Yandex Translate": "Yandex Tłumacz",
@@ -492,7 +491,6 @@
"File Content (recommended)": "Zawartość pliku (zalecane)",
"File Name": "Nazwa pliku",
"Device Name": "Nazwa urządzenia",
"Sync Tolerance": "Tolerancja synchronizacji",
"Connect to your KOReader Sync server.": "Połącz z serwerem synchronizacji KOReader.",
"Server URL": "Adres URL serwera",
"Username": "Nazwa użytkownika",
@@ -511,6 +509,20 @@
"Approximately {{percentage}}%": "Około {{percentage}}%",
"Failed to connect": "Nie udało się połączyć",
"Sync Server Connected": "Serwer synchronizacji połączony",
"Precision: {{precision}} digits after the decimal": "Precyzja: {{precision}} cyfr po przecinku",
"Sync as {{userDisplayName}}": "Synchronizuj jako {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Synchronizuj jako {{userDisplayName}}",
"Custom Fonts": "Niestandardowe Czcionki",
"Cancel Delete": "Anuluj Usuwanie",
"Import Font": "Importuj Czcionkę",
"Delete Font": "Usuń Czcionkę",
"Tips": "Wskazówki",
"Custom fonts can be selected from the Font Face menu": "Niestandardowe czcionki można wybrać z menu Czcionka",
"Manage Custom Fonts": "Zarządzaj Niestandardowymi Czcionkami",
"Select Files": "Wybierz Pliki",
"Select Image": "Wybierz Obraz",
"Select Video": "Wybierz Wideo",
"Select Audio": "Wybierz Audio",
"Select Fonts": "Wybierz Czcionki",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Obsługiwane formaty czcionek: .ttf, .otf, .woff, .woff2",
"Push Progress": "Wyślij postęp",
"Pull Progress": "Pobierz postęp"
}
@@ -447,7 +447,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Ficção, Ciência, História",
"Select Cover Image": "Selecionar imagem da capa",
"Open Book in New Window": "Abrir livro em nova janela",
"Voices for {{lang}}": "Vozes para {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -488,7 +487,6 @@
"File Content (recommended)": "Conteúdo do Arquivo (recomendado)",
"File Name": "Nome do Arquivo",
"Device Name": "Nome do Dispositivo",
"Sync Tolerance": "Tolerância de Sincronização",
"Connect to your KOReader Sync server.": "Conectar ao seu servidor KOReader Sync.",
"Server URL": "URL do Servidor",
"Username": "Nome de Usuário",
@@ -507,6 +505,20 @@
"Approximately {{percentage}}%": "Aproximadamente {{percentage}}%",
"Failed to connect": "Falha ao conectar",
"Sync Server Connected": "Servidor de Sincronização Conectado",
"Precision: {{precision}} digits after the decimal": "Precisão: {{precision}} dígitos após a vírgula",
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
"Custom Fonts": "Fontes Personalizadas",
"Cancel Delete": "Cancelar Exclusão",
"Import Font": "Importar Fonte",
"Delete Font": "Excluir Fonte",
"Tips": "Dicas",
"Custom fonts can be selected from the Font Face menu": "Fontes personalizadas podem ser selecionadas no menu Fonte",
"Manage Custom Fonts": "Gerenciar Fontes Personalizadas",
"Select Files": "Selecionar Arquivos",
"Select Image": "Selecionar Imagem",
"Select Video": "Selecionar Vídeo",
"Select Audio": "Selecionar Áudio",
"Select Fonts": "Selecionar Fontes",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formatos de fonte suportados: .ttf, .otf, .woff, .woff2",
"Push Progress": "Enviar Progresso",
"Pull Progress": "Obter Progresso"
}
@@ -451,7 +451,6 @@
"Google Books": "Google Книги",
"Open Library": "Open Library",
"Fiction, Science, History": "Художественная литература, Наука, История",
"Select Cover Image": "Выбрать изображение обложки",
"Open Book in New Window": "Открыть книгу в новом окне",
"Voices for {{lang}}": "Голоса для {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -492,7 +491,6 @@
"File Content (recommended)": "Содержимое файла (рекомендуется)",
"File Name": "Имя файла",
"Device Name": "Имя устройства",
"Sync Tolerance": "Допуск синхронизации",
"Connect to your KOReader Sync server.": "Подключитесь к вашему серверу синхронизации KOReader.",
"Server URL": "URL сервера",
"Username": "Имя пользователя",
@@ -511,6 +509,20 @@
"Approximately {{percentage}}%": "Приблизительно {{percentage}}%",
"Failed to connect": "Не удалось подключиться",
"Sync Server Connected": "Сервер синхронизации подключен",
"Precision: {{precision}} digits after the decimal": "Точность: {{precision}} знаков после запятой",
"Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}",
"Custom Fonts": "Пользовательские Шрифты",
"Cancel Delete": "Отменить Удаление",
"Import Font": "Импортировать Шрифт",
"Delete Font": "Удалить Шрифт",
"Tips": "Советы",
"Custom fonts can be selected from the Font Face menu": "Пользовательские шрифты можно выбрать в меню Шрифт",
"Manage Custom Fonts": "Управление Пользовательскими Шрифтами",
"Select Files": "Выбрать Файлы",
"Select Image": "Выбрать Изображение",
"Select Video": "Выбрать Видео",
"Select Audio": "Выбрать Аудио",
"Select Fonts": "Выбрать Шрифты",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Поддерживаемые форматы шрифтов: .ttf, .otf, .woff, .woff2",
"Push Progress": "Отправить прогресс",
"Pull Progress": "Получить прогресс"
}
@@ -439,7 +439,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์",
"Select Cover Image": "เลือกภาพปก",
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่",
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}",
"Yandex Translate": "Yandex Translate",
@@ -480,7 +479,6 @@
"File Content (recommended)": "เนื้อหาไฟล์ (แนะนำ)",
"File Name": "ชื่อไฟล์",
"Device Name": "ชื่ออุปกรณ์",
"Sync Tolerance": "ความทนทานต่อการซิงค์",
"Connect to your KOReader Sync server.": "เชื่อมต่อกับเซิร์ฟเวอร์ KOReader Sync ของคุณ",
"Server URL": "URL ของเซิร์ฟเวอร์",
"Username": "ชื่อผู้ใช้",
@@ -499,6 +497,20 @@
"Approximately {{percentage}}%": "ประมาณ {{percentage}}%",
"Failed to connect": "ไม่สามารถเชื่อมต่อได้",
"Sync Server Connected": "เซิร์ฟเวอร์ซิงค์เชื่อมต่อแล้ว",
"Precision: {{precision}} digits after the decimal": "ความแม่นยำ: {{precision}} หลักหลังจุดทศนิยม",
"Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}"
"Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}",
"Custom Fonts": "ฟอนต์กำหนดเอง",
"Cancel Delete": "ยกเลิกการลบ",
"Import Font": "นำเข้าฟอนต์",
"Delete Font": "ลบฟอนต์",
"Tips": "เคล็ดลับ",
"Custom fonts can be selected from the Font Face menu": "ฟอนต์กำหนดเองสามารถเลือกได้จากเมนูฟอนต์",
"Manage Custom Fonts": "จัดการฟอนต์กำหนดเอง",
"Select Files": "เลือกไฟล์",
"Select Image": "เลือกรูปภาพ",
"Select Video": "เลือกวิดีโอ",
"Select Audio": "เลือกเสียง",
"Select Fonts": "เลือกฟอนต์",
"Supported font formats: .ttf, .otf, .woff, .woff2": "รูปแบบฟอนต์ที่รองรับ: .ttf, .otf, .woff, .woff2",
"Push Progress": "ส่งความคืบหน้า",
"Pull Progress": "ดึงความคืบหน้า"
}
@@ -443,7 +443,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Kurgu, Bilim, Tarih",
"Select Cover Image": "Kapak Resmi Seç",
"Open Book in New Window": "Kitabı Yeni Pencerede Aç",
"Voices for {{lang}}": "{{lang}} için Sesler",
"Yandex Translate": "Yandex Çeviri",
@@ -484,7 +483,6 @@
"File Content (recommended)": "Dosya İçeriği (önerilen)",
"File Name": "Dosya Adı",
"Device Name": "Cihaz Adı",
"Sync Tolerance": "Senkronizasyon Toleransı",
"Connect to your KOReader Sync server.": "KOReader Senkronizasyon sunucunuza bağlanın.",
"Server URL": "Sunucu URL'si",
"Username": "Kullanıcı Adı",
@@ -503,6 +501,20 @@
"Approximately {{percentage}}%": "Yaklaşık {{percentage}}%",
"Failed to connect": "Bağlanma başarısız oldu",
"Sync Server Connected": "Senkronizasyon Sunucusu Bağlandı",
"Precision: {{precision}} digits after the decimal": "Hassasiyet: {{precision}} ondalık basamaktan sonra",
"Sync as {{userDisplayName}}": "Senkronize et {{userDisplayName}} olarak"
"Sync as {{userDisplayName}}": "Senkronize et {{userDisplayName}} olarak",
"Custom Fonts": "Özel Yazı Tipleri",
"Cancel Delete": "Silmeyi İptal Et",
"Import Font": "Yazı Tipi İçe Aktar",
"Delete Font": "Yazı Tipini Sil",
"Tips": "İpuçları",
"Custom fonts can be selected from the Font Face menu": "Özel yazı tipleri Yazı Tipi menüsünden seçilebilir",
"Manage Custom Fonts": "Özel Yazı Tiplerini Yönet",
"Select Files": "Dosyaları Seç",
"Select Image": "Resim Seç",
"Select Video": "Video Seç",
"Select Audio": "Ses Seç",
"Select Fonts": "Yazı Tiplerini Seç",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Desteklenen yazı tipi formatları: .ttf, .otf, .woff, .woff2",
"Push Progress": "İlerlemeyi Gönder",
"Pull Progress": "İlerlemeyi Al"
}
@@ -451,7 +451,6 @@
"Google Books": "Google Книги",
"Open Library": "Open Library",
"Fiction, Science, History": "Художня література, Наукова фантастика, Історія",
"Select Cover Image": "Оберіть зображення обкладинки",
"Open Book in New Window": "Відкрити книгу в новому вікні",
"Voices for {{lang}}": "Голоси для {{lang}}",
"Yandex Translate": "Yandex Перекладач (росія)",
@@ -492,7 +491,6 @@
"File Content (recommended)": "Вміст файлу (рекомендується)",
"File Name": "Ім'я файлу",
"Device Name": "Ім'я пристрою",
"Sync Tolerance": "Допустима похибка синхронізації",
"Connect to your KOReader Sync server.": "Підключіться до свого сервера синхронізації KOReader.",
"Server URL": "URL сервера",
"Username": "Ім'я користувача",
@@ -511,6 +509,20 @@
"Approximately {{percentage}}%": "Приблизно {{percentage}}%",
"Failed to connect": "Не вдалося під'єднатися",
"Sync Server Connected": "Сервер синхронізації під'єднано",
"Precision: {{precision}} digits after the decimal": "Точність: {{precision}} знаків після коми",
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}",
"Custom Fonts": "Користувацькі Шрифти",
"Cancel Delete": "Скасувати Видалення",
"Import Font": "Імпортувати Шрифт",
"Delete Font": "Видалити Шрифт",
"Tips": "Поради",
"Custom fonts can be selected from the Font Face menu": "Користувацькі шрифти можна вибрати з меню Шрифт",
"Manage Custom Fonts": "Керування Користувацькими Шрифтами",
"Select Files": "Вибрати Файли",
"Select Image": "Вибрати Зображення",
"Select Video": "Вибрати Відео",
"Select Audio": "Вибрати Аудіо",
"Select Fonts": "Вибрати Шрифти",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Підтримувані формати шрифтів: .ttf, .otf, .woff, .woff2",
"Push Progress": "Надіслати прогрес",
"Pull Progress": "Отримати прогрес"
}
@@ -439,7 +439,6 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Tiểu thuyết, Khoa học, Lịch sử",
"Select Cover Image": "Chọn ảnh bìa",
"Open Book in New Window": "Mở Sách trong Cửa sổ Mới",
"Voices for {{lang}}": "Giọng nói cho {{lang}}",
"Yandex Translate": "Yandex Dịch",
@@ -480,7 +479,6 @@
"File Content (recommended)": "Nội dung tệp (được khuyến nghị)",
"File Name": "Tên tệp",
"Device Name": "Tên thiết bị",
"Sync Tolerance": "Độ dung sai đồng bộ",
"Connect to your KOReader Sync server.": "Kết nối với máy chủ đồng bộ KOReader của bạn.",
"Server URL": "URL máy chủ",
"Username": "Tên người dùng",
@@ -499,6 +497,20 @@
"Approximately {{percentage}}%": "Khoảng {{percentage}}%",
"Failed to connect": "Không thể kết nối",
"Sync Server Connected": "Máy chủ đồng bộ đã kết nối",
"Precision: {{precision}} digits after the decimal": "Độ chính xác: {{precision}} chữ số sau dấu thập phân",
"Sync as {{userDisplayName}}": "Đồng bộ dưới tên {{userDisplayName}}"
"Sync as {{userDisplayName}}": "Đồng bộ dưới tên {{userDisplayName}}",
"Custom Fonts": "Phông Chữ Tùy Chỉnh",
"Cancel Delete": "Hủy Xóa",
"Import Font": "Nhập Phông Chữ",
"Delete Font": "Xóa Phông Chữ",
"Tips": "Mẹo",
"Custom fonts can be selected from the Font Face menu": "Phông chữ tùy chỉnh có thể được chọn từ menu Phông Chữ",
"Manage Custom Fonts": "Quản Lý Phông Chữ Tùy Chỉnh",
"Select Files": "Chọn Tệp",
"Select Image": "Chọn Hình Ảnh",
"Select Video": "Chọn Video",
"Select Audio": "Chọn Âm Thanh",
"Select Fonts": "Chọn Phông Chữ",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Định dạng phông chữ được hỗ trợ: .ttf, .otf, .woff, .woff2",
"Push Progress": "Đẩy tiến độ",
"Pull Progress": "Kéo tiến độ"
}
@@ -439,7 +439,6 @@
"Google Books": "谷歌图书",
"Open Library": "开放图书馆",
"Fiction, Science, History": "小说,科学,历史",
"Select Cover Image": "选择封面图片",
"Open Book in New Window": "在新窗口中打开书籍",
"Voices for {{lang}}": "{{lang}} 的语音",
"Yandex Translate": "Yandex 翻译",
@@ -480,7 +479,6 @@
"File Content (recommended)": "文件内容(推荐)",
"File Name": "文件名",
"Device Name": "设备名称",
"Sync Tolerance": "同步容忍度",
"Connect to your KOReader Sync server.": "连接到您的 KOReader 同步服务器。",
"Server URL": "服务器 URL",
"Username": "用户名",
@@ -499,6 +497,20 @@
"Approximately {{percentage}}%": "大约 {{percentage}}%",
"Failed to connect": "连接失败",
"Sync Server Connected": "同步服务器已连接",
"Precision: {{precision}} digits after the decimal": "精度:{{precision}} 位小数",
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步"
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步",
"Custom Fonts": "自定义字体",
"Cancel Delete": "取消删除",
"Import Font": "导入字体",
"Delete Font": "删除字体",
"Tips": "提示",
"Custom fonts can be selected from the Font Face menu": "可以从字形菜单中选择自定义字体",
"Manage Custom Fonts": "管理自定义字体",
"Select Files": "选择文件",
"Select Image": "选择图片",
"Select Video": "选择视频",
"Select Audio": "选择音频",
"Select Fonts": "选择字体",
"Supported font formats: .ttf, .otf, .woff, .woff2": "支持的字体格式:.ttf, .otf, .woff, .woff2",
"Push Progress": "推送阅读进度",
"Pull Progress": "拉取阅读进度"
}
@@ -439,7 +439,6 @@
"Google Books": "Google 圖書",
"Open Library": "開放圖書館",
"Fiction, Science, History": "小說,科學,歷史",
"Select Cover Image": "選擇封面圖片",
"Open Book in New Window": "在新視窗中打開書籍",
"Voices for {{lang}}": "{{lang}} 的語音",
"Yandex Translate": "Yandex 翻譯",
@@ -480,7 +479,6 @@
"File Content (recommended)": "文件內容(推薦)",
"File Name": "文件名",
"Device Name": "設備名稱",
"Sync Tolerance": "同步容忍度",
"Connect to your KOReader Sync server.": "連接到您的 KOReader 同步伺服器。",
"Server URL": "伺服器 URL",
"Username": "用戶名",
@@ -499,6 +497,20 @@
"Approximately {{percentage}}%": "大約 {{percentage}}%",
"Failed to connect": "連接失敗",
"Sync Server Connected": "同步伺服器已連接",
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 位小數",
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步"
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步",
"Custom Fonts": "自訂字型",
"Cancel Delete": "取消刪除",
"Import Font": "匯入字型",
"Delete Font": "刪除字型",
"Tips": "提示",
"Custom fonts can be selected from the Font Face menu": "可以從字型選單中選擇自訂字型",
"Manage Custom Fonts": "管理自訂字型",
"Select Files": "選擇檔案",
"Select Image": "選擇圖片",
"Select Video": "選擇影片",
"Select Audio": "選擇音訊",
"Select Fonts": "選擇字型",
"Supported font formats: .ttf, .otf, .woff, .woff2": "支援的字型格式:.ttf, .otf, .woff, .woff2",
"Push Progress": "推送閱讀進度",
"Pull Progress": "拉取閱讀進度"
}
+28
View File
@@ -1,5 +1,33 @@
{
"releases": {
"0.9.76": {
"date": "2025-08-30",
"notes": [
"Sync: Moved KOReader sync controls into the reader sidebar with a manual push/pull option",
"Sync: Improved tolerance and reliability for KOReader sync operations",
"Sync: Added support for more KOReader server implementations",
"Fonts: Added support for importing multiple styles within a font family",
"Reader: Fixed an issue where footnotes are empty in some EPUB files",
"Theme: Applied theme background color for PDFs on macOS",
"Reader: Added support for importing AZW3 files on Android",
"Reader: Reduced screen flash when launching the app and opening books",
"Layout: Fixed an issue where the grouping modal would not appear on Linux"
]
},
"0.9.75": {
"date": "2025-08-22",
"notes": [
"Fonts: Added support for importing local TTF and ODF fonts",
"TTS: Reduced the pause between sentences when using Edge TTS",
"TTS: Fixed an issue where voices list could not be opened on older versions of iOS",
"TOC: Fixed an issue where nested TOC items would not collapse properly in some books",
"Translator: Fixed an issue where translations in the popup did not refresh correctly",
"Library: Added option to sort authors with last name first",
"Layout: Fixed mismatch in close button position between the reader and library pages",
"Layout: Book title now remains centered in the available space",
"Layout: Fixed footer bar layout in landscape mode"
]
},
"0.9.72": {
"date": "2025-08-18",
"notes": [
@@ -30,27 +30,55 @@
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/epub+zip" />
<data android:mimeType="application/pdf" />
<data android:mimeType="application/fb2" />
<data android:mimeType="application/x-fb2" />
<data android:mimeType="application/mobi"/>
<data android:mimeType="application/azw"/>
<data android:mimeType="application/azw3"/>
<data android:mimeType="application/x-mobipocket-ebook" />
<data android:mimeType="application/vnd.amazon.ebook" />
<data android:mimeType="application/vnd.amazon.mobi8-ebook" />
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/x-cbz" />
<data android:mimeType="application/x-mobipocket-ebook" />
<data android:mimeType="application/zip" />
<data android:mimeType="text/plain" />
<data android:mimeType="application/octet-stream"/>
<data android:mimeType="application/x-font-ttf"/>
<data android:mimeType="application/x-font-otf"/>
</intent-filter>
<data android:pathPattern="/.*\\.epub" />
<data android:pathPattern="/.*\\.pdf" />
<data android:pathPattern="/.*\\.fb2" />
<data android:pathPattern="/.*\\.fb2.zip" />
<data android:pathPattern="/.*\\.mobi" />
<data android:pathPattern="/.*\\.azw" />
<data android:pathPattern="/.*\\.cbz" />
<data android:pathPattern="/.*\\.zip" />
<data android:pathPattern="/.*\\.txt" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.epub" />
<data android:pathPattern=".*\\.pdf" />
<data android:pathPattern=".*\\.fb2" />
<data android:pathPattern=".*\\.fb2.zip" />
<data android:pathPattern=".*\\.mobi" />
<data android:pathPattern=".*\\.azw" />
<data android:pathPattern=".*\\.azw3" />
<data android:pathPattern=".*\\.cbz" />
<data android:pathPattern=".*\\.zip" />
<data android:pathPattern=".*\\.txt" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/>
</intent-filter>
<intent-filter android:label="oauth">
@@ -59,6 +87,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="readest" android:host="auth-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -66,7 +95,7 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter android:autoVerify="true">
<intent-filter android:autoVerify="true" >
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,8 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="#FF323130" />
</item>
<item android:gravity="center" android:width="120dp" android:height="120dp">
<bitmap android:src="@drawable/splash_icon" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

@@ -2,9 +2,7 @@
<!-- Base application theme. -->
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowSplashScreenBackground">@android:color/transparent</item>
<item name="android:windowSplashScreenAnimatedIcon" tools:targetApi="31">@null</item>
<item name="android:windowBackground">@drawable/splash_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">false</item>
@@ -198,6 +198,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@Suppress("DEPRECATION")
window.setDecorFitsSystemWindows(false)
val controller = window.insetsController
if (controller != null) {
@@ -228,7 +229,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val compatController = WindowCompat.getInsetsController(window, decorView)
compatController?.let {
compatController.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (!isDarkMode) {
it.isAppearanceLightStatusBars = true
@@ -259,7 +260,9 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
}.reduce { acc, flag -> acc or flag }
}
@Suppress("DEPRECATION")
window.statusBarColor = Color.TRANSPARENT
@Suppress("DEPRECATION")
window.navigationBarColor = Color.TRANSPARENT
ret.put("success", true)
} catch (e: Exception) {
@@ -366,4 +369,37 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve()
}
@Command
fun get_safe_area_insets(invoke: Invoke) {
val ret = JSObject()
try {
val rootView = activity.findViewById<View>(android.R.id.content)
val windowInsets = androidx.core.view.ViewCompat.getRootWindowInsets(rootView)
if (windowInsets != null) {
val insets = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars() or
WindowInsetsCompat.Type.displayCutout()
)
val density = activity.resources.displayMetrics.density
ret.put("top", insets.top / density)
ret.put("right", insets.right / density)
ret.put("bottom", insets.bottom / density)
ret.put("left", insets.left / density)
} else {
ret.put("top", 0)
ret.put("right", 0)
ret.put("bottom", 0)
ret.put("left", 0)
}
} catch (e: Exception) {
ret.put("error", e.message)
ret.put("top", 0)
ret.put("right", 0)
ret.put("bottom", 0)
ret.put("left", 0)
}
invoke.resolve(ret)
}
}
@@ -14,6 +14,7 @@ const COMMANDS: &[&str] = &[
"iap_purchase_product",
"iap_restore_purchases",
"get_system_color_scheme",
"get_safe_area_insets",
];
fn main() {
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-safe-area-insets"
description = "Enables the get_safe_area_insets command without any pre-configured scope."
commands.allow = ["get_safe_area_insets"]
[[permission]]
identifier = "deny-get-safe-area-insets"
description = "Denies the get_safe_area_insets command without any pre-configured scope."
commands.deny = ["get_safe_area_insets"]
@@ -19,6 +19,7 @@ Default permissions for the plugin
- `allow-iap-purchase-product`
- `allow-iap-restore-purchases`
- `allow-get-system-color-scheme`
- `allow-get-safe-area-insets`
## Permission Table
@@ -110,6 +111,32 @@ Denies the copy_uri_to_path command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-safe-area-insets`
</td>
<td>
Enables the get_safe_area_insets command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-safe-area-insets`
</td>
<td>
Denies the get_safe_area_insets command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-get-status-bar-height`
</td>
@@ -16,4 +16,5 @@ permissions = [
"allow-iap-purchase-product",
"allow-iap-restore-purchases",
"allow-get-system-color-scheme",
"allow-get-safe-area-insets",
]
@@ -330,6 +330,18 @@
"const": "deny-copy-uri-to-path",
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Enables the get_safe_area_insets command without any pre-configured scope.",
"type": "string",
"const": "allow-get-safe-area-insets",
"markdownDescription": "Enables the get_safe_area_insets command without any pre-configured scope."
},
{
"description": "Denies the get_safe_area_insets command without any pre-configured scope.",
"type": "string",
"const": "deny-get-safe-area-insets",
"markdownDescription": "Denies the get_safe_area_insets command without any pre-configured scope."
},
{
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
"type": "string",
@@ -475,10 +487,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`",
"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`",
"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`"
"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`"
}
]
}
@@ -119,3 +119,10 @@ pub(crate) async fn get_system_color_scheme<R: Runtime>(
) -> Result<GetSystemColorSchemeResponse> {
app.native_bridge().get_system_color_scheme()
}
#[command]
pub(crate) async fn get_safe_area_insets<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetSafeAreaInsetsResponse> {
app.native_bridge().get_safe_area_insets()
}
@@ -102,4 +102,8 @@ impl<R: Runtime> NativeBridge<R> {
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -52,6 +52,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::iap_purchase_product,
commands::iap_restore_purchases,
commands::get_system_color_scheme,
commands::get_safe_area_insets,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -161,3 +161,11 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
self.0
.run_mobile_plugin("get_safe_area_insets", ())
.map_err(Into::into)
}
}
@@ -158,3 +158,12 @@ pub struct IAPRestorePurchasesResponse {
pub struct GetSystemColorSchemeResponse {
pub color_scheme: String, // "light" or "dark"
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSafeAreaInsetsResponse {
pub top: f64,
pub bottom: f64,
pub left: f64,
pub right: f64,
}
+4 -1
View File
@@ -6,6 +6,7 @@ extern crate cocoa;
#[macro_use]
extern crate objc;
use tauri::utils::config::BackgroundThrottlingPolicy;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -240,7 +241,9 @@ pub fn run() {
eprintln!("Failed to initialize tauri_plugin_log: {e}");
};
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.background_throttling(BackgroundThrottlingPolicy::Disabled)
.background_color(tauri::window::Color(50, 49, 48, 255));
#[cfg(desktop)]
let win_builder = win_builder.inner_size(800.0, 600.0).resizable(true);
+8 -1
View File
@@ -86,11 +86,18 @@
},
{
"name": "azw",
"ext": ["azw", "azw3"],
"ext": ["azw"],
"description": "AZW file",
"mimeType": "application/vnd.amazon.ebook",
"role": "Viewer"
},
{
"name": "azw3",
"ext": ["azw3"],
"description": "AZW3 file",
"mimeType": "application/vnd.amazon.mobi8-ebook",
"role": "Viewer"
},
{
"name": "fb2",
"ext": ["fb2"],
+5 -5
View File
@@ -67,7 +67,7 @@ export default function AuthPage() {
const router = useRouter();
const { login } = useAuth();
const { envConfig, appService } = useEnv();
const { isDarkMode } = useThemeStore();
const { isDarkMode, safeAreaInsets } = useThemeStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [port, setPort] = useState<number | null>(null);
@@ -351,10 +351,10 @@ export default function AuthPage() {
)}
>
<div
className={clsx(
'flex h-full w-full flex-col items-center overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
className={clsx('flex h-full w-full flex-col items-center overflow-y-auto')}
style={{
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<div
ref={headerRef}
+11 -1
View File
@@ -37,7 +37,6 @@ export const viewport = {
maximumScale: 1,
userScalable: false,
viewportFit: 'cover',
themeColor: 'white',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -45,6 +44,17 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html>
<head>
<title>{title}</title>
<script
dangerouslySetInnerHTML={{
__html: `
const themeMode = localStorage.getItem('themeMode');
const themeColor = localStorage.getItem('themeColor');
if (themeMode && themeColor) {
document.documentElement.setAttribute('data-theme', \`\${themeColor}-\${themeMode}\`);
}
`,
}}
/>
<meta
name='viewport'
content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover'
@@ -8,6 +8,7 @@ import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -53,6 +54,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
@@ -239,8 +241,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author);
const bAuthors = formatAuthors(b.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();
@@ -346,7 +348,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<Spinner loading />
</div>
)}
<div className='fixed bottom-0 left-0 right-0 z-40 pb-[calc(env(safe-area-inset-bottom)+16px)]'>
<div
className='fixed bottom-0 left-0 right-0 z-40'
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
{isSelectMode && showSelectModeActions && (
<div
className={clsx(
@@ -425,10 +432,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
{showDeleteAlert && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
title={_('Confirm Deletion')}
@@ -9,7 +9,6 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getLocalBookFilename } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
@@ -192,7 +191,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getLocalBookFilename(book)}`;
const folder = `${settings.localBooksDir}/${book.hash}`;
revealItemInDir(folder);
},
});
@@ -13,7 +13,6 @@ import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { debounce } from '@/utils/debounce';
@@ -59,7 +58,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const headerRef = useRef<HTMLDivElement>(null);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
const insets = useSafeAreaInsets();
const { safeAreaInsets: insets } = useThemeStore();
useShortcuts({
onToggleSelectMode,
@@ -95,7 +94,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService?.hasTrafficLight]);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
@@ -110,7 +109,8 @@ 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] sm:pr-6',
'titlebar bg-base-200 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',
)}
style={{
@@ -8,7 +8,6 @@ import { TbSunMoon } from 'react-icons/tb';
import { BiMoon, BiSun } from 'react-icons/bi';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setKOSyncSettingsWindowVisible } from './KOSyncSettings';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
@@ -159,11 +158,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsTelemetryEnabled(settings.telemetryEnabled);
};
const showKoSyncSettingsWindow = () => {
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handleUpgrade = () => {
navigateToProfile(router);
setIsDropdownOpen?.(false);
@@ -274,8 +268,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={cycleThemeMode}
/>
<hr className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
<hr className='border-base-200 my-1' />
{user && userPlan === 'free' && !appService?.isIOSApp && (
<MenuItem label={_('Upgrade to Readest Premium')} onClick={handleUpgrade} />
)}
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useSync } from '@/hooks/useSync';
import { useLibraryStore } from '@/store/libraryStore';
import { Book } from '@/types/book';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { debounce } from '@/utils/debounce';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
export interface UseBooksSyncProps {
onSyncStart?: () => void;
@@ -17,12 +17,11 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
const { appService } = useEnv();
const { library, setLibrary } = useLibraryStore();
const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const syncBooksPullingRef = useRef(false);
const pullLibrary = async () => {
const pullLibrary = useCallback(async () => {
if (!user) return;
syncBooks([], 'pull');
};
await syncBooks([], 'pull');
}, [user, syncBooks]);
const pushLibrary = async () => {
if (!user) return;
@@ -32,15 +31,13 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
useEffect(() => {
if (!user) return;
if (syncBooksPullingRef.current) return;
syncBooksPullingRef.current = true;
pullLibrary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [user]);
const getNewBooks = () => {
if (!user) return [];
const library = useLibraryStore.getState().library;
const newBooks = library.filter(
(book) => lastSyncedAtBooks < book.updatedAt || lastSyncedAtBooks < (book.deletedAt ?? 0),
);
@@ -53,14 +50,14 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
const newBooks = getNewBooks();
syncBooks(newBooks, 'both');
}, SYNC_BOOKS_INTERVAL_SEC * 1000),
[library, lastSyncedAtBooks],
[syncBooks],
);
useEffect(() => {
if (!user) return;
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [library]);
}, [library, handleAutoSync]);
const updateLibrary = async () => {
if (!syncedBooks?.length) return;
+31 -70
View File
@@ -10,25 +10,21 @@ import 'overlayscrollbars/overlayscrollbars.css';
import { Book } from '@/types/book';
import { AppService, DeleteAction } from '@/types/system';
import { navigateToLogin, navigateToReader } from '@/utils/nav';
import {
formatAuthors,
formatTitle,
getFilename,
getPrimaryLanguage,
listFormater,
} from '@/utils/book';
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { getFilename } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { BOOK_ACCEPT_FORMATS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -37,9 +33,9 @@ import { useTheme } from '@/hooks/useTheme';
import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation } from '@/utils/bridge';
import {
tauriHandleSetAlwaysOnTop,
@@ -48,7 +44,6 @@ import {
} from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { KOSyncSettingsWindow } from './components/KOSyncSettings';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { BookMetadata } from '@/libs/document';
import { BookDetailModal } from '@/components/metadata';
@@ -78,7 +73,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setCheckLastOpenBooks,
} = useLibraryStore();
const _ = useTranslation();
const insets = useSafeAreaInsets();
const { selectFiles } = useFileSelector(appService, _);
const { safeAreaInsets: insets } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
@@ -153,12 +149,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
} else {
fileExt = file.name.split('.').pop()?.toLowerCase();
}
return FILE_ACCEPT_FORMATS.includes(`.${fileExt}`);
return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`);
});
if (supportedFiles.length === 0) {
eventDispatcher.dispatch('toast', {
message: _('No supported files found. Supported formats: {{formats}}', {
formats: FILE_ACCEPT_FORMATS,
formats: BOOK_ACCEPT_FORMATS,
}),
type: 'error',
});
@@ -169,7 +165,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
impactFeedback('medium');
}
await importBooks(supportedFiles);
const selectedFiles = supportedFiles.map(
(file) =>
({
file: typeof file === 'string' ? undefined : file,
path: typeof file === 'string' ? file : undefined,
}) as SelectedFile,
);
await importBooks(selectedFiles);
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
@@ -397,7 +400,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [demoBooks, libraryLoaded]);
const importBooks = async (files: (string | File)[]) => {
const importBooks = async (files: SelectedFile[]) => {
setLoading(true);
const failedFiles = [];
const errorMap: [string, string][] = [
@@ -406,7 +409,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
['Unsupported format.', _('This book format is not supported.')],
];
const { library } = useLibraryStore.getState();
for (const file of files) {
for (const selectedFile of files) {
const file = selectedFile.file || selectedFile.path;
if (!file) continue;
try {
const book = await appService?.importBook(file, library);
setLibrary([...library]);
@@ -436,32 +441,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setLoading(false);
};
const selectFilesTauri = async () => {
const exts = appService?.isIOSApp ? [] : SUPPORTED_FILE_EXTS;
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
if (appService?.isIOSApp) {
return files.filter((file) => {
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
return SUPPORTED_FILE_EXTS.includes(fileExt);
});
}
return files;
};
const selectFilesWeb = () => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = FILE_ACCEPT_FORMATS;
fileInput.multiple = true;
fileInput.click();
fileInput.onchange = () => {
resolve(fileInput.files);
};
});
};
const updateBookTransferProgress = throttle((bookHash: string, progress: ProgressPayload) => {
if (progress.total === 0) return;
const progressPct = (progress.progress / progress.total) * 100;
@@ -610,14 +589,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const handleImportBooks = async () => {
setIsSelectMode(false);
console.log('Importing books...');
let files;
if (isTauriAppPlatform()) {
files = (await selectFilesTauri()) as string[];
} else {
files = (await selectFilesWeb()) as File[];
}
importBooks(files);
selectFiles({ type: 'books', multiple: true }).then((result) => {
if (result.files.length === 0 || result.error) return;
importBooks(result.files);
});
};
const handleSetSelectMode = (selectMode: boolean) => {
@@ -643,19 +618,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setShowDetailsBook(book);
};
if (!appService || !insets) {
return null;
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
return <div className='bg-base-200 h-[100vh]' />;
}
if (checkOpenWithBooks || checkLastOpenBooks) {
return (
loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)
);
}
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
return (
<div
@@ -682,11 +649,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<Spinner loading />
</div>
)}
{libraryLoaded &&
{showBookshelf &&
(libraryBooks.some((book) => !book.deletedAt) ? (
<OverlayScrollbarsComponent
defer
ref={osRef}
className='flex-grow'
options={{ scrollbars: { autoHide: 'scroll' } }}
events={{
initialized: (instance) => {
@@ -754,7 +722,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
/>
)}
<AboutWindow />
<KOSyncSettingsWindow />
<UpdaterWindow />
<Toast />
</div>
@@ -763,13 +730,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const LibraryPage = () => {
return (
<Suspense
fallback={
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
}
>
<Suspense fallback={<div className='bg-base-200 h-[100vh]' />}>
<LibraryPageWithSearchParams />
</Suspense>
);
+2 -9
View File
@@ -1,14 +1,7 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import LibraryPage from './library/page';
export default function HomePage() {
const router = useRouter();
useEffect(() => {
router.replace('/library');
}, [router]);
return null;
return <LibraryPage />;
}
@@ -2,11 +2,11 @@ import clsx from 'clsx';
import React, { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import FoliateViewer from './FoliateViewer';
@@ -34,7 +34,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const screenInsets = useSafeAreaInsets();
const { safeAreaInsets: screenInsets } = useThemeStore();
const aspectRatio = window.innerWidth / window.innerHeight;
const gridTemplate = getGridTemplate(bookKeys.length, aspectRatio);
@@ -7,6 +7,8 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePagination } from '../hooks/usePagination';
@@ -21,7 +23,7 @@ import {
getStyles,
transformStylesheet,
} from '@/utils/style';
import { mountAdditionalFonts } from '@/utils/font';
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
@@ -43,6 +45,7 @@ import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import Spinner from '@/components/Spinner';
import ConfirmSyncDialog from './ConfirmSyncDialog';
declare global {
@@ -57,12 +60,14 @@ const FoliateViewer: React.FC<{
config: BookConfig;
contentInsets: Insets;
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
const { appService, envConfig } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const { settings } = useSettingsStore();
const { loadCustomFonts, getLoadedFonts } = useCustomFontStore();
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey);
const viewRef = useRef<FoliateView | null>(null);
@@ -70,6 +75,8 @@ const FoliateViewer: React.FC<{
const isViewCreated = useRef(false);
const doubleClickDisabled = useRef(!!viewSettings?.disableDoubleClick);
const [toastMessage, setToastMessage] = useState('');
const [loading, setLoading] = useState(false);
const docLoaded = useRef(false);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
@@ -79,12 +86,8 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
const {
syncState,
conflictDetails,
resolveConflictWithLocal,
resolveConflictWithRemote,
} = useKOSync(bookKey);
const { syncState, conflictDetails, resolveConflictWithLocal, resolveConflictWithRemote } =
useKOSync(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
@@ -127,6 +130,8 @@ const FoliateViewer: React.FC<{
};
const docLoadHandler = (event: Event) => {
setLoading(false);
docLoaded.current = true;
const detail = (event as CustomEvent).detail;
console.log('doc index loaded:', detail.index);
if (detail.doc) {
@@ -144,6 +149,10 @@ const FoliateViewer: React.FC<{
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
getLoadedFonts().forEach((font) => {
mountCustomFont(detail.doc, font);
});
if (bookDoc.rendition?.layout === 'pre-paginated') {
applyFixedlayoutStyles(detail.doc, viewSettings);
}
@@ -224,6 +233,8 @@ const FoliateViewer: React.FC<{
if (isViewCreated.current) return;
isViewCreated.current = true;
setTimeout(() => setLoading(true), 200);
const openBook = async () => {
console.log('Opening book', bookKey);
await import('foliate-js/view.js');
@@ -335,6 +346,21 @@ const FoliateViewer: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
useEffect(() => {
const mountCustomFonts = async () => {
await loadCustomFonts(envConfig);
getLoadedFonts().forEach((font) => {
mountCustomFont(document, font);
const docs = viewRef.current?.renderer.getContents();
docs?.forEach(({ doc }) => mountCustomFont(doc, font));
});
};
if (settings.customFonts) {
mountCustomFonts();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.customFonts, envConfig]);
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
@@ -364,6 +390,7 @@ const FoliateViewer: React.FC<{
{...mouseHandlers}
{...touchHandlers}
/>
{!docLoaded.current && loading && <Spinner loading={true} />}
{syncState === 'conflict' && conflictDetails && (
<ConfirmSyncDialog
details={conflictDetails}
@@ -187,7 +187,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
appService?.isMobile ? 'fixed' : 'absolute',
// See: https://github.com/readest/readest/issues/1716
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
@@ -366,7 +367,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
</span>
<input
type='range'
className='text-base-content mx-2 w-full'
className='text-base-content mx-2 min-w-0 flex-1'
min={0}
max={100}
value={progressValid ? progressFraction * 100 : 0}
@@ -7,7 +7,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import { mountAdditionalFonts } from '@/utils/font';
import { mountAdditionalFonts } from '@/styles/fonts';
import { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
import { isCJKLang } from '@/utils/lang';
@@ -40,6 +40,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const {
isTrafficLightVisible,
trafficLightInFullscreen,
setTrafficLightVisibility,
initializeTrafficLightStore,
@@ -52,6 +53,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const { isSideBarVisible } = useSidebarStore();
const iconSize16 = useResponsiveSize(16);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const handleToggleDropdown = (isOpen: boolean) => {
setIsDropdownOpen(isOpen);
if (!isOpen) setHoveredBookKey('');
@@ -133,9 +136,19 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<TranslationToggler bookKey={bookKey} />
</div>
<div className='header-title z-15 bg-base-100 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
<h2 className='line-clamp-1 max-w-[50%] text-center text-xs font-semibold'>
{bookTitle}
<div
className={clsx(
'header-title z-15 bg-base-100 pointer-events-none hidden flex-1 items-center justify-center sm:flex',
!windowButtonVisible && 'absolute inset-0',
)}
>
<h2
className={clsx(
'line-clamp-1 text-center text-xs font-semibold',
!windowButtonVisible && 'max-w-[50%]',
)}
>
{bookTitle} {bookTitle} {bookTitle} {bookTitle}
</h2>
</div>
@@ -154,16 +167,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<WindowButtons
className='window-buttons flex h-full items-center'
headerRef={headerRef}
showMinimize={
bookKeys.length == 1 &&
!appService?.hasTrafficLight &&
appService?.appPlatform !== 'web'
}
showMaximize={
bookKeys.length == 1 &&
!appService?.hasTrafficLight &&
appService?.appPlatform !== 'web'
}
showMinimize={bookKeys.length == 1 && windowButtonVisible}
showMaximize={bookKeys.length == 1 && windowButtonVisible}
onClose={() => {
setHoveredBookKey(null);
onCloseBook(bookKey);
@@ -1,17 +1,16 @@
import clsx from 'clsx';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { md5 } from 'js-md5';
import clsx from 'clsx';
import { type as osType } from '@tauri-apps/plugin-os';
import Dialog from '@/components/Dialog';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useEnv } from '@/context/EnvContext';
import { eventDispatcher } from '@/utils/event';
import { KOSyncClient } from '@/services/sync/KOSyncClient';
import { KoreaderSyncChecksumMethod, KoreaderSyncStrategy } from '@/types/settings';
import { v4 as uuidv4 } from 'uuid';
import { KOSyncChecksumMethod, KOSyncStrategy } from '@/types/settings';
import { debounce } from '@/utils/debounce';
import { getOSPlatform } from '@/utils/misc';
import Dialog from '@/components/Dialog';
type Option = {
value: string;
@@ -69,24 +68,20 @@ export const KOSyncSettingsWindow: React.FC = () => {
const { envConfig, appService } = useEnv();
const [isOpen, setIsOpen] = useState(false);
const [url, setUrl] = useState(settings.koreaderSyncServerUrl || '');
const [username, setUsername] = useState(settings.koreaderSyncUsername || '');
const [url, setUrl] = useState(settings.kosync.serverUrl || '');
const [username, setUsername] = useState(settings.kosync.username || '');
const [password, setPassword] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('');
const [deviceName, setDeviceName] = useState('');
const [osName, setOsName] = useState('');
const [toleranceSliderValue, setToleranceSliderValue] = useState(() => {
const tolerance = settings.koreaderSyncPercentageTolerance;
return tolerance && tolerance > 0 ? Math.round(-Math.log10(tolerance)) : 4;
});
// Get the OS name once
useEffect(() => {
const formatOsName = (name: string): string => {
if (!name) return '';
if (name.toLowerCase() === 'macos') return 'macOS';
if (name.toLowerCase() === 'ios') return 'iOS';
return name.charAt(0).toUpperCase() + name.slice(1);
};
@@ -107,13 +102,10 @@ export const KOSyncSettingsWindow: React.FC = () => {
useEffect(() => {
const defaultName = osName ? `Readest (${osName})` : 'Readest';
setDeviceName(settings.koreaderSyncDeviceName || defaultName);
}, [settings.koreaderSyncDeviceName, osName]);
setDeviceName(settings.kosync.deviceName || defaultName);
}, [settings.kosync.deviceName, osName]);
const isConfigured = useMemo(
() => !!settings.koreaderSyncUserkey,
[settings.koreaderSyncUserkey],
);
const isConfigured = useMemo(() => !!settings.kosync.userkey, [settings.kosync.userkey]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSaveDeviceName = useCallback(
@@ -135,15 +127,10 @@ export const KOSyncSettingsWindow: React.FC = () => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(event.detail.visible);
if (event.detail.visible) {
setUrl(settings.koreaderSyncServerUrl || '');
setUsername(settings.koreaderSyncUsername || '');
setUrl(settings.kosync.serverUrl || '');
setUsername(settings.kosync.username || '');
setPassword('');
setConnectionStatus('');
// Sync the slider with the current settings when opening
const tolerance = settings.koreaderSyncPercentageTolerance;
setToleranceSliderValue(
tolerance && tolerance > 0 ? Math.round(-Math.log10(tolerance)) : 4,
);
}
};
const el = document.getElementById('kosync_settings_window');
@@ -151,40 +138,26 @@ export const KOSyncSettingsWindow: React.FC = () => {
return () => {
el?.removeEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
};
}, [
settings.koreaderSyncServerUrl,
settings.koreaderSyncUsername,
settings.koreaderSyncPercentageTolerance,
]);
}, [settings.kosync.serverUrl, settings.kosync.username]);
const handleConnect = async () => {
setIsConnecting(true);
let deviceId = settings.koreaderSyncDeviceId;
if (!deviceId) {
deviceId = uuidv4().replace(/-/g, '').toUpperCase();
}
const client = new KOSyncClient(
url,
const config = {
...settings.kosync,
serverUrl: url,
username,
md5(password),
settings.koreaderSyncChecksumMethod,
deviceId,
userkey: md5(password),
deviceName,
);
enabled: true,
};
const client = new KOSyncClient(config);
const result = await client.connect(username, password);
if (result.success) {
const newSettings = {
...settings,
koreaderSyncServerUrl: url,
koreaderSyncUsername: username,
koreaderSyncUserkey: md5(password),
koreaderSyncDeviceId: deviceId,
koreaderSyncDeviceName: deviceName,
koreaderSyncStrategy:
settings.koreaderSyncStrategy === 'disabled' ? 'prompt' : settings.koreaderSyncStrategy,
kosync: config,
};
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
@@ -200,11 +173,12 @@ export const KOSyncSettingsWindow: React.FC = () => {
};
const handleDisconnect = async () => {
const newSettings = {
...settings,
koreaderSyncStrategy: 'disabled' as KoreaderSyncStrategy,
koreaderSyncUserkey: '',
const kosync = {
...settings.kosync,
userkey: '',
enabled: false,
};
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
setUsername('');
@@ -212,26 +186,23 @@ export const KOSyncSettingsWindow: React.FC = () => {
};
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newStrategy = e.target.value as KoreaderSyncStrategy;
const newSettings = { ...settings, koreaderSyncStrategy: newStrategy };
const kosync = {
...settings.kosync,
strategy: e.target.value as KOSyncStrategy,
};
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const handleChecksumMethodChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newMethod = e.target.value as KoreaderSyncChecksumMethod;
const newSettings = { ...settings, koreaderSyncChecksumMethod: newMethod };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
const kosync = {
...settings.kosync,
checksumMethod: e.target.value as KOSyncChecksumMethod,
};
const handleToleranceChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const sliderValue = parseInt(e.target.value, 10);
setToleranceSliderValue(sliderValue);
// Calculate the actual tolerance from the slider value (e.g., 4 -> 0.0001)
const newTolerance = Math.pow(10, -sliderValue);
const newSettings = { ...settings, koreaderSyncPercentageTolerance: newTolerance };
const newSettings = { ...settings, kosync };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
};
@@ -250,18 +221,16 @@ export const KOSyncSettingsWindow: React.FC = () => {
<div className='text-center'>
<p className='text-base-content/80 text-sm'>
{_('Sync as {{userDisplayName}}', {
userDisplayName: settings.koreaderSyncUsername,
userDisplayName: settings.kosync.username,
})}
</p>
</div>
<div className='flex h-14 items-center justify-between'>
<span className='text-base-content/80'>
{_('Sync Server Connected', { username: settings.koreaderSyncUsername })}
</span>
<span className='text-base-content/80'>{_('Sync Server Connected')}</span>
<input
type='checkbox'
className='toggle'
checked={settings.koreaderSyncStrategy !== 'disabled'}
checked={settings.kosync.enabled}
onChange={() => handleDisconnect()}
/>
</div>
@@ -270,14 +239,13 @@ export const KOSyncSettingsWindow: React.FC = () => {
<span className='label-text font-medium'>{_('Sync Strategy')}</span>
</label>
<StyledSelect
value={settings.koreaderSyncStrategy}
value={settings.kosync.strategy}
onChange={handleStrategyChange}
options={[
{ value: 'prompt', label: _('Ask on conflict') },
{ value: 'silent', label: _('Always use latest') },
{ value: 'send', label: _('Send changes only') },
{ value: 'receive', label: _('Receive changes only') },
{ value: 'disable', label: _('Disabled') },
]}
/>
</div>
@@ -286,7 +254,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
<span className='label-text font-medium'>{_('Checksum Method')}</span>
</label>
<StyledSelect
value={settings.koreaderSyncChecksumMethod}
value={settings.kosync.checksumMethod}
onChange={handleChecksumMethodChange}
options={[
{ value: 'binary', label: _('File Content (recommended)') },
@@ -306,27 +274,6 @@ export const KOSyncSettingsWindow: React.FC = () => {
onChange={handleDeviceNameChange}
/>
</div>
{/* Hidden to avoid confusing users with technical details */}
{false && (
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Sync Tolerance')}</span>
</label>
<input
type='range'
min='0'
max='15'
value={toleranceSliderValue}
onChange={handleToleranceChange}
className='range range-primary'
/>
<div className='text-base-content/70 mt-2 text-center text-xs'>
{_('Precision: {{precision}} digits after the decimal', {
precision: toleranceSliderValue,
})}
</div>
</div>
)}
</>
) : (
<>
@@ -346,31 +293,35 @@ export const KOSyncSettingsWindow: React.FC = () => {
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Username')}</span>
</label>
<input
type='text'
placeholder={_('Your Username')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Password')}</span>
</label>
<input
type='password'
placeholder={_('Your Password')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<form className='flex flex-col gap-4'>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Username')}</span>
</label>
<input
type='text'
placeholder={_('Your Username')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
spellCheck='false'
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete='username'
/>
</div>
<div className='form-control w-full'>
<label className='label py-1'>
<span className='label-text font-medium'>{_('Password')}</span>
</label>
<input
type='password'
placeholder={_('Your Password')}
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete='current-password'
/>
</div>
</form>
<button
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
onClick={handleConnect}
@@ -16,11 +16,12 @@ import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { eventDispatcher } from '@/utils/event';
import { interceptWindowOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/utils/font';
import { mountAdditionalFonts } from '@/styles/fonts';
import { isTauriAppPlatform } from '@/services/environment';
import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { KOSyncSettingsWindow } from './KOSyncSettings';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
import { initDayjs } from '@/utils/time';
@@ -106,24 +107,25 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey]);
return (
libraryLoaded &&
settings.globalReadSettings && (
<div
className={clsx(
`reader-page text-base-content select-none overflow-hidden`,
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<UpdaterWindow />
<Toast />
</Suspense>
</div>
)
return libraryLoaded && settings.globalReadSettings ? (
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none overflow-hidden`,
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense fallback={<div className='h-[100vh]'></div>}>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<UpdaterWindow />
<KOSyncSettingsWindow />
<Toast />
</Suspense>
</div>
) : (
<div className='bg-base-100 h-[100vh]'></div>
);
};
@@ -111,7 +111,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (isPrimary && book && config) {
const settings = useSettingsStore.getState().settings;
eventDispatcher.dispatch('sync-book-progress', { bookKey });
eventDispatcher.dispatch('flush-koreader-sync', { bookKey });
eventDispatcher.dispatch('flush-kosync', { bookKey });
await saveConfig(envConfig, bookKey, config, settings);
}
};
@@ -142,7 +142,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const handleCloseBooksToLibrary = () => {
handleCloseBooks();
navigateToLibrary(router);
if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow();
if (currentWindow.label === 'main') {
navigateToLibrary(router);
} else {
currentWindow.close();
}
} else {
navigateToLibrary(router);
}
};
const handleCloseBook = async (bookKey: string) => {
@@ -170,7 +179,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const bookData = getBookData(bookKeys[0]!);
const viewSettings = getViewSettings(bookKeys[0]!);
if (!bookData || !bookData.book || !bookData.bookDoc || !viewSettings) {
setTimeout(() => setLoading(true), 300);
setTimeout(() => setLoading(true), 200);
return (
loading && (
<div className={clsx('hero hero-content', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
@@ -1,17 +1,20 @@
import clsx from 'clsx';
import React from 'react';
import { useThemeStore } from '@/store/themeStore';
interface RibbonProps {
width: string;
}
const Ribbon: React.FC<RibbonProps> = ({}) => {
const { safeAreaInsets } = useThemeStore();
return (
<div
className={clsx(
'ribbon absolute inset-0 z-10 flex w-8 justify-center sm:w-6',
'h-[calc(env(safe-area-inset-top)+44px)]',
)}
className={clsx('ribbon absolute inset-0 z-10 flex w-8 justify-center sm:w-6')}
style={{
height: `${(safeAreaInsets?.top || 0) + 44}px`,
}}
>
<svg
width='100%'
@@ -129,7 +129,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
fetchTranslation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, token, sourceLang, targetLang, provider]);
}, [text, token, sourceLang, targetLang, provider, translate]);
return (
<div>
@@ -176,9 +176,12 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
className='bg-gray-600 text-white/75'
value={targetLang}
onChange={handleTargetLangChange}
options={Object.entries(translatorLangs)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => ({ value: code, label: name }))}
options={[
{ value: '', label: _('System Language') },
...Object.entries(translatorLangs)
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([code, name]) => ({ value: code, label: name })),
]}
/>
</div>
{loading ? (
@@ -25,7 +25,7 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
const Notebook: React.FC = ({}) => {
const _ = useTranslation();
const { updateAppTheme } = useThemeStore();
const { updateAppTheme, safeAreaInsets } = useThemeStore();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
@@ -196,7 +196,6 @@ const Notebook: React.FC = ({}) => {
'notebook-container bg-base-200 right-0 z-20 flex min-w-60 select-none flex-col',
'font-sans text-base font-normal sm:text-sm',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
)}
@@ -205,6 +204,7 @@ const Notebook: React.FC = ({}) => {
width: `${notebookWidth}`,
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
position: isNotebookPinned ? 'relative' : 'absolute',
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<style jsx>{`
@@ -187,7 +187,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
<div className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Color')}</h2>
<h2 className='font-medium'>{_('Override Book Color')}</h2>
<input
type='checkbox'
className='toggle'
@@ -0,0 +1,209 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { MdAdd, MdDelete } from 'react-icons/md';
import { IoMdCloseCircleOutline } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useCustomFontStore } from '@/store/customFontStore';
import { useFileSelector } from '@/hooks/useFileSelector';
import { mountCustomFont } from '@/styles/fonts';
import { parseFontName } from '@/utils/font';
import { getFilename } from '@/utils/path';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
interface CustomFontsProps {
bookKey: string;
onBack: () => void;
}
const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
const {
fonts: customFonts,
addFont,
loadFont,
removeFont,
getAvailableFonts,
saveCustomFonts,
} = useCustomFontStore();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const [isDeleteMode, setIsDeleteMode] = useState(false);
const { selectFiles } = useFileSelector(appService, _);
const currentDefaultFont =
viewSettings.defaultFont.toLowerCase() === 'serif' ? 'serif' : 'sans-serif';
const currentFontFamily =
currentDefaultFont === 'serif' ? viewSettings.serifFont : viewSettings.sansSerifFont;
const handleImportFont = () => {
selectFiles({ type: 'fonts', multiple: true }).then(async (result) => {
if (result.error || result.files.length === 0) return;
if (!(await appService!.fs.exists('', 'Fonts'))) {
await appService!.fs.createDir('', 'Fonts');
}
for (const selectedFile of result.files) {
let fontPath: string;
let fontFile: File;
if (selectedFile.path) {
const filePath = selectedFile.path;
fontPath = getFilename(filePath);
await appService!.fs.copyFile(filePath, fontPath, 'Fonts');
fontFile = await appService!.fs.openFile(fontPath, 'Fonts');
} else if (selectedFile.file) {
const file = selectedFile.file;
fontPath = getFilename(file.name);
await appService!.fs.writeFile(fontPath, 'Fonts', file);
fontFile = file;
} else {
continue;
}
const fontName = parseFontName(await fontFile.arrayBuffer(), fontPath);
const customFont = addFont(fontPath, {
name: fontName.name,
family: fontName.family,
style: fontName.style,
});
if (customFont && !customFont.error) {
const loadedFont = await loadFont(envConfig, customFont.id);
mountCustomFont(document, loadedFont);
}
}
saveCustomFonts(envConfig);
});
};
const handleDeleteFont = (fontId: string) => {
const font = customFonts.find((f) => f.id === fontId);
if (font) {
if (removeFont(fontId)) {
appService!.fs.removeFile(font.path, 'Fonts');
saveCustomFonts(envConfig);
if (getAvailableFonts().length === 0) {
setIsDeleteMode(false);
}
}
}
};
const handleSelectFont = (fontId: string) => {
const font = customFonts.find((f) => f.id === fontId);
if (font) {
if (currentDefaultFont === 'serif') {
saveViewSettings(envConfig, bookKey, 'serifFont', font.name);
} else {
saveViewSettings(envConfig, bookKey, 'sansSerifFont', font.name);
}
}
};
const toggleDeleteMode = () => {
setIsDeleteMode(!isDeleteMode);
};
const availableFonts = customFonts
.filter((font) => !font.deletedAt)
.sort((a, b) => (b.downloadedAt || 0) - (a.downloadedAt || 0));
return (
<div className='w-full'>
<div className='mb-6 flex h-8 items-center justify-between'>
<div className='breadcrumbs py-1'>
<ul>
<li>
<a className='font-semibold' onClick={onBack}>
{_('Font')}
</a>
</li>
<li className='font-medium'>{_('Custom Fonts')}</li>
</ul>
</div>
{availableFonts.length > 0 && (
<button
onClick={toggleDeleteMode}
className={`btn btn-ghost btn-sm text-base-content gap-2`}
title={isDeleteMode ? _('Cancel Delete') : _('Delete Font')}
>
{isDeleteMode ? (
<>{_('Cancel')}</>
) : (
<>
<MdDelete className='h-4 w-4' />
{_('Delete')}
</>
)}
</button>
)}
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='card border-primary/50 hover:border-primary/75 group h-12 border-2 transition-colors'>
<div
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
onClick={handleImportFont}
>
<div className='flex items-center gap-2'>
<div className='flex items-center justify-center'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
</div>
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
{_('Import Font')}
</div>
</div>
</div>
</div>
{availableFonts.map((font) => (
<div
key={font.id}
className={clsx(
'card h-12 border shadow-sm',
currentFontFamily === font.name
? 'border-primary/50 bg-primary/50'
: `border-base-200 bg-base-200 ${isDeleteMode ? '' : 'cursor-pointer'}`,
)}
onClick={!isDeleteMode ? () => handleSelectFont(font.id) : undefined}
title={font.name}
>
<div className='card-body flex items-center justify-center p-2'>
<div
style={{
fontFamily: font.loaded ? `"${font.name}", sans-serif` : 'sans-serif',
fontWeight: 400,
}}
className='text-base-content line-clamp-1 break-all'
>
{font.family || font.name}
</div>
{isDeleteMode && (
<button
onClick={() => handleDeleteFont(font.id)}
className='btn btn-ghost btn-xs absolute right-[-10px] top-[-10px] h-6 min-h-0 w-6 p-0 hover:bg-transparent'
title={_('Delete Font')}
>
<IoMdCloseCircleOutline className='text-base-content/75 h-6 w-6' />
</button>
)}
</div>
</div>
))}
</div>
<div className='bg-base-200/30 my-8 rounded-lg p-4'>
<div className='text-base-content/70 text-sm sm:text-xs'>
<div className='mb-1 indent-2 font-medium'>{_('Tips')}:</div>
<ul className='list-outside list-disc space-y-1 ps-2'>
<li>{_('Supported font formats: .ttf, .otf, .woff, .woff2')}</li>
<li>{_('Custom fonts can be selected from the Font Face menu')}</li>
</ul>
</div>
</div>
</div>
);
};
export default CustomFonts;
@@ -14,10 +14,16 @@ interface DialogMenuProps {
resetLabel?: string;
}
const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, resetLabel }) => {
const DialogMenu: React.FC<DialogMenuProps> = ({
activePanel,
setIsDropdownOpen,
onReset,
resetLabel,
}) => {
const _ = useTranslation();
const iconSize = useResponsiveSize(16);
const { isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } = useSettingsStore();
const { setFontPanelView, isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } =
useSettingsStore();
const handleToggleGlobal = () => {
setFontLayoutSettingsGlobal(!isFontLayoutSettingsGlobal);
@@ -29,6 +35,11 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, res
setIsDropdownOpen?.(false);
};
const handleManageCustomFont = () => {
setFontPanelView('custom-fonts');
setIsDropdownOpen?.(false);
};
return (
<div
tabIndex={0}
@@ -49,6 +60,9 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, res
onClick={handleToggleGlobal}
/>
<MenuItem label={resetLabel || _('Reset Settings')} onClick={handleResetToDefaults} />
{activePanel === 'Font' && (
<MenuItem label={_('Manage Custom Fonts')} onClick={handleManageCustomFont} />
)}
</div>
);
};
@@ -34,19 +34,18 @@ const FontItem: React.FC<FontItemProps> = ({ index, style, data }) => {
const option = options[index]!;
return (
<li
className='px-1 sm:px-2'
key={option.option}
style={style}
onClick={() => onSelect(option.option)}
>
<li className='px-2' key={option.option} style={style} onClick={() => onSelect(option.option)}>
<div className='flex w-full items-center overflow-hidden !px-0 text-sm'>
<span style={{ minWidth: `${iconSize16}px` }}>
{selected === option.option && (
<MdCheck className='text-base-content' size={iconSize16} />
)}
</span>
<span style={{ fontFamily: onGetFontFamily(option.option, family) }}>
<span
className='line-clamp-1 overflow-visible break-all leading-loose'
style={{ fontFamily: onGetFontFamily(option.option, family) }}
title={option.label || option.option}
>
{option.label || option.option}
</span>
</div>
@@ -105,7 +104,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
>
<div className='flex items-center gap-x-1'>
<span
className='text-ellipsis'
className='line-clamp-1 overflow-visible break-all leading-loose'
style={{
fontFamily: onGetFontFamily(selectedOption.option, family ?? ''),
}}
@@ -138,7 +137,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
{/* More options with nested dropdown */}
{moreOptions && moreOptions.length > 0 && (
<li className='dropdown dropdown-left dropdown-top px-1 sm:px-2'>
<li className='dropdown dropdown-left dropdown-top px-2'>
<div className='flex items-center px-0 text-sm'>
<span style={{ minWidth: `${iconSize}px` }}>
<FiChevronLeft size={iconSize} />
@@ -1,11 +1,11 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { MdSettings } from 'react-icons/md';
import {
ANDROID_FONTS,
CJK_EXCLUDE_PATTENS,
CJK_FONTS_PATTENS,
CJK_NAMES_PATTENS,
CJK_SANS_SERIF_FONTS,
CJK_SERIF_FONTS,
IOS_FONTS,
@@ -17,27 +17,33 @@ import {
SERIF_FONTS,
WINDOWS_FONTS,
} from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
import { getSysFontsList } from '@/utils/bridge';
import { isCJKStr } from '@/utils/lang';
import { isTauriAppPlatform } from '@/services/environment';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { useResetViewSettings } from '../../hooks/useResetSettings';
import { SettingsPanelPanelProp } from './SettingsDialog';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
import CustomFonts from './CustomFonts';
const genCJKFontsList = (sysFonts: string[]) => {
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
.filter((font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font))
.filter((font) => CJK_FONTS_PATTENS.test(font) || isCJKStr(font))
.filter((font) => !CJK_EXCLUDE_PATTENS.test(font))
.sort((a, b) => a.localeCompare(b));
};
const isSymbolicFontName = (font: string) =>
/emoji|icons|symbol|dingbats|ornaments|webdings|wingdings|miuiex/i.test(font);
interface FontFaceProps {
className?: string;
family: string;
@@ -83,10 +89,19 @@ const FontFace = ({
const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const { fontPanelView, setFontPanelView } = useSettingsStore();
const {
fonts: allCustomFonts,
getAllFonts,
getFontFamilies,
removeFont,
saveCustomFonts,
} = useCustomFontStore();
const viewSettings = getViewSettings(bookKey)!;
const view = getView(bookKey)!;
const iconSize18 = useResponsiveSize(18);
const fontFamilyOptions = [
{
@@ -130,8 +145,10 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
const [customFonts, setCustomFonts] = useState<string[]>(getFontFamilies());
const [CJKFonts, setCJKFonts] = useState<string[]>(() => {
return genCJKFontsList(sysFonts);
return genCJKFontsList([...customFonts, ...sysFonts]);
});
const resetToDefaults = useResetViewSettings();
@@ -148,19 +165,43 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
monospaceFont: setMonospaceFont,
fontWeight: setFontWeight,
});
getAllFonts().forEach((font) => {
if (removeFont(font.id)) {
appService!.fs.removeFile(font.path, 'Fonts');
}
});
saveCustomFonts(envConfig);
};
const handleManageCustomFonts = () => {
setFontPanelView('custom-fonts');
};
const handleBackToMain = () => {
setFontPanelView('main-fonts');
};
useEffect(() => {
onRegisterReset(handleReset);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService]);
useEffect(() => {
setCJKFonts((prev) => {
const newFonts = genCJKFontsList(sysFonts);
const newFonts = genCJKFontsList([...customFonts, ...sysFonts]);
return prev.length !== newFonts.length ? newFonts : prev;
});
}, [sysFonts]);
}, [customFonts, sysFonts]);
useEffect(() => {
setCustomFonts(getFontFamilies());
}, [allCustomFonts, getFontFamilies]);
useEffect(() => {
setSerifFont(viewSettings.serifFont);
setSansSerifFont(viewSettings.sansSerifFont);
setMonospaceFont(viewSettings.monospaceFont);
}, [viewSettings.serifFont, viewSettings.sansSerifFont, viewSettings.monospaceFont]);
useEffect(() => {
if (isTauriAppPlatform()) {
@@ -247,10 +288,18 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
}
};
if (fontPanelView === 'custom-fonts') {
return (
<div className='my-4 w-full'>
<CustomFonts bookKey={bookKey} onBack={handleBackToMain} />
</div>
);
}
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Font')}</h2>
<h2 className='font-medium'>{_('Override Book Font')}</h2>
<input
type='checkbox'
className='toggle'
@@ -326,14 +375,27 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Face')}</h2>
<div className='mb-2 flex items-center justify-between'>
<h2 className='font-medium'>{_('Font Face')}</h2>
<button
onClick={handleManageCustomFonts}
className='btn btn-ghost btn-xs gap-1 hover:bg-transparent'
title={_('Manage Custom Fonts')}
>
<MdSettings size={iconSize18} />
</button>
</div>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<FontFace
className='config-item-top'
family='serif'
label={_('Serif Font')}
options={[...SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SERIF_FONTS]}
options={[
...customFonts,
...SERIF_FONTS.filter(filterNonFreeFonts),
...CJK_SERIF_FONTS,
]}
moreOptions={sysFonts}
selected={serifFont}
onSelect={setSerifFont}
@@ -341,7 +403,11 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<FontFace
family='sans-serif'
label={_('Sans-Serif Font')}
options={[...SANS_SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SANS_SERIF_FONTS]}
options={[
...customFonts,
...SANS_SERIF_FONTS.filter(filterNonFreeFonts),
...CJK_SANS_SERIF_FONTS,
]}
moreOptions={sysFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
@@ -350,7 +416,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
className='config-item-bottom'
family='monospace'
label={_('Monospace Font')}
options={MONOSPACE_FONTS}
options={[...customFonts, ...MONOSPACE_FONTS]}
moreOptions={sysFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
@@ -370,7 +370,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className=''>{_('Override Book Layout')}</h2>
<h2 className='font-medium'>{_('Override Book Layout')}</h2>
<input
type='checkbox'
className='toggle'
@@ -40,7 +40,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
const tabsRef = useRef<HTMLDivElement | null>(null);
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const { setFontPanelView, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const tabConfig = [
{
@@ -85,6 +85,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
const handleSetActivePanel = (tab: SettingsPanelType) => {
setActivePanel(tab);
setFontPanelView('main-fonts');
localStorage.setItem('lastConfigPanel', tab);
};
@@ -115,6 +116,8 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
};
useEffect(() => {
setFontPanelView('main-fonts');
const container = tabsRef.current;
if (!container) return;
@@ -154,7 +157,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, []);
}, [setFontPanelView]);
const currentPanel = tabConfig.find((tab) => tab.tab === activePanel);
@@ -8,10 +8,12 @@ import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
import useBooksManager from '../../hooks/useBooksManager';
import MenuItem from '@/components/MenuItem';
@@ -22,6 +24,7 @@ interface BookMenuProps {
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { bookKeys, getViewSettings, setViewSettings } = useReaderStore();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
@@ -69,6 +72,18 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
unsetParallel(bookKeys);
setIsDropdownOpen?.(false);
};
const showKoSyncSettingsWindow = () => {
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handlePullKOSync = () => {
eventDispatcher.dispatch('pull-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const handlePushKOSync = () => {
eventDispatcher.dispatch('push-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const isWebApp = isWebAppPlatform();
@@ -93,6 +108,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<ul className='max-h-60 overflow-y-auto'>
{getVisibleLibrary()
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
.filter((book) => !!book.downloadedAt)
.slice(0, 20)
.map((book) => (
<MenuItem
@@ -117,6 +133,14 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
</ul>
</MenuItem>
<hr className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
{settings.kosync.enabled && (
<>
<MenuItem label={_('Push Progress')} onClick={handlePushKOSync} />
<MenuItem label={_('Pull Progress')} onClick={handlePullKOSync} />
</>
)}
<hr className='border-base-200 my-1' />
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
<MenuItem
label={_('Sort TOC by Page')}
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
@@ -15,7 +15,7 @@ const SidebarContent: React.FC<{
bookDoc: BookDoc;
sideBarBookKey: string;
}> = ({ bookDoc, sideBarBookKey }) => {
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
@@ -75,10 +75,10 @@ const SidebarContent: React.FC<{
</OverlayScrollbarsComponent>
</div>
<div
className={clsx(
'flex-shrink-0',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom)/2)]',
)}
className='flex-shrink-0'
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) / 2}px`,
}}
>
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
</div>
@@ -1,10 +1,10 @@
import clsx from 'clsx';
import React from 'react';
import React, { useEffect } from 'react';
import { GiBookshelf } from 'react-icons/gi';
import { FiSearch } from 'react-icons/fi';
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import Dropdown from '@/components/Dropdown';
@@ -18,11 +18,30 @@ const SidebarHeader: React.FC<{
onTogglePin: () => void;
onToggleSearchBar: () => void;
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
const { isTrafficLightVisible } = useTrafficLightStore();
const { appService } = useEnv();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
initializeTrafficLightListeners,
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const iconSize14 = useResponsiveSize(14);
const iconSize18 = useResponsiveSize(18);
const iconSize22 = useResponsiveSize(22);
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.hasTrafficLight]);
return (
<div
className={clsx(
@@ -29,7 +29,7 @@ const SideBar: React.FC<{
onGoToLibrary: () => void;
}> = ({ onGoToLibrary }) => {
const { appService } = useEnv();
const { updateAppTheme } = useThemeStore();
const { updateAppTheme, safeAreaInsets } = useThemeStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { getBookData } = useBookDataStore();
@@ -186,7 +186,6 @@ const SideBar: React.FC<{
'sidebar-container bg-base-200 z-20 flex min-w-60 select-none flex-col',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
'transition-[padding-top] duration-300',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
)}
@@ -195,6 +194,7 @@ const SideBar: React.FC<{
width: `${sideBarWidth}`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
position: isSideBarPinned ? 'relative' : 'absolute',
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<style jsx>{`
@@ -13,12 +13,17 @@ import { getContentMd5 } from '@/utils/misc';
import { useTextTranslation } from '../../hooks/useTextTranslation';
import { FlatTOCItem, StaticListRow, VirtualListRow } from './TOCItem';
const getItemIdentifier = (item: TOCItem) => {
const href = item.href || '';
return `toc-item-${item.id}-${href}`;
};
const useFlattenedTOC = (toc: TOCItem[], expandedItems: Set<string>) => {
return useMemo(() => {
const flattenTOC = (items: TOCItem[], depth = 0): FlatTOCItem[] => {
const result: FlatTOCItem[] = [];
items.forEach((item, index) => {
const isExpanded = expandedItems.has(item.href || '');
const isExpanded = expandedItems.has(getItemIdentifier(item));
result.push({ item, depth, index, isExpanded });
if (item.subitems && isExpanded) {
result.push(...flattenTOC(item.subitems, depth + 1));
@@ -126,13 +131,13 @@ const TOCView: React.FC<{
}, [flatItems, activeHref]);
const handleToggleExpand = useCallback((item: TOCItem) => {
const href = item.href || '';
const itemId = getItemIdentifier(item);
setExpandedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(href)) {
newSet.delete(href);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(href);
newSet.add(itemId);
}
return newSet;
});
@@ -149,9 +154,10 @@ const TOCView: React.FC<{
);
const expandParents = useCallback((toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
const parentHrefs = parentPath.filter(Boolean) as string[];
setExpandedItems(new Set(parentHrefs));
const parentItems = findParentPath(toc, href)
.map((item) => getItemIdentifier(item))
.filter(Boolean);
setExpandedItems(new Set(parentItems));
}, []);
const scrollToActiveItem = useCallback(() => {
@@ -176,7 +182,8 @@ const TOCView: React.FC<{
(activeItem as HTMLElement).setAttribute('aria-current', 'page');
}
}
}, [activeHref, flatItems]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeHref]);
const virtualItemSize = useMemo(() => {
return window.innerWidth >= 640 && !viewSettings?.translationEnabled ? 37 : 57;
@@ -210,7 +217,7 @@ const TOCView: React.FC<{
setTimeout(scrollToActiveItem, appService?.isAndroidApp ? 300 : 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flatItems, scrollToActiveItem]);
}, [scrollToActiveItem]);
return flatItems.length > 256 ? (
<div
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -27,6 +28,7 @@ interface TTSControlProps {
const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { getBookData } = useBookDataStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
@@ -429,10 +431,13 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
className={clsx(
'absolute h-12 w-12',
viewSettings?.rtl ? 'left-6' : 'right-6',
appService?.hasSafeAreaInset
? 'bottom-[calc(env(safe-area-inset-bottom)+70px)]'
: 'bottom-[70px] sm:bottom-14',
!appService?.hasSafeAreaInset && 'bottom-[70px] sm:bottom-14',
)}
style={{
bottom: appService?.hasSafeAreaInset
? `${(safeAreaInsets?.bottom || 0) + 70}px`
: undefined,
}}
>
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
</div>
@@ -13,14 +13,18 @@ const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
return (
<div
className={clsx(
'relative h-full w-full',
'relative h-full w-full rounded-full',
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
)}
style={{
maskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
WebkitMaskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
}}
onClick={onClick}
>
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
<div
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
className='absolute -inset-full rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
style={{
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
}}
@@ -248,6 +248,7 @@ const TTSPanel = ({
<button
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1'
onClick={(e) => e.currentTarget.focus()}
>
<MdAlarm size={iconSize32} />
{timeoutCountdown && (
@@ -290,7 +291,11 @@ const TTSPanel = ({
</div>
<div className='dropdown dropdown-top'>
<button tabIndex={0} className='rounded-full p-1'>
<button
tabIndex={0}
className='rounded-full p-1'
onClick={(e) => e.currentTarget.focus()}
>
<RiVoiceAiFill size={iconSize32} />
</button>
<ul
+209 -372
View File
@@ -1,17 +1,15 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { md5 } from 'js-md5';
import { type as osType } from '@tauri-apps/plugin-os';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { KOSyncClient, KoSyncProgress } from '@/services/sync/KOSyncClient';
import { Book, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { Book, BookProgress, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { getCFIFromXPointer, XCFI } from '@/utils/xcfi';
import { getCFIFromXPointer, normalizeProgressXPointer, XCFI } from '@/utils/xcfi';
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
@@ -30,386 +28,262 @@ export interface SyncDetails {
export const useKOSync = (bookKey: string) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { getProgress, getView } = useReaderStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const progress = getProgress(bookKey);
const [kosyncClient, setKOSyncClient] = useState<KOSyncClient | null>(null);
const [syncState, setSyncState] = useState<SyncState>('idle');
const [conflictDetails, setConflictDetails] = useState<SyncDetails | null>(null);
const [errorMessage] = useState<string | null>(null);
const hasPulledOnce = useRef(false);
const syncCompletedForKey = useRef<string | null>(null);
const lastPushedCfiRef = useRef<string | null>(null);
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
const progress = getProgress(bookKey);
useEffect(() => {
lastPushedCfiRef.current = null;
syncCompletedForKey.current = null;
setSyncState('idle');
}, [bookKey]);
if (!settings.kosync.username || !settings.kosync.userkey) {
setKOSyncClient(null);
return;
}
const client = new KOSyncClient(settings.kosync);
setKOSyncClient(client);
}, [settings]);
const mapProgressToServerFormat = useCallback(() => {
const currentProgress = getProgress(bookKey);
const currentBook = getBookData(bookKey)?.book;
if (!currentProgress || !currentBook) return null;
const generateKOProgress = useCallback(() => {
const progress = getProgress(bookKey);
const book = getBookData(bookKey)?.book;
if (!progress || !book) return null;
let progressStr: string;
let koProgress = '';
let percentage: number;
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
const page = (currentProgress.section?.current ?? 0) + 1;
const totalPages = currentProgress.section?.total ?? 0;
progressStr = page.toString();
percentage = totalPages > 0 ? page / totalPages : 0;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const page = progress.section?.current ?? 0;
const totalPages = progress.section?.total ?? 0;
koProgress = page.toString();
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
} else {
progressStr = currentProgress.location;
const view = getView(bookKey);
if (view && progressStr) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index: spineIndex } = content;
const converter = new XCFI(doc, spineIndex || 0);
const xpointerResult = converter.cfiToXPointer(progressStr);
progressStr = xpointerResult.xpointer;
}
} catch (error) {
console.error(
'Failed to convert CFI to XPointer. Progress will be sent as percentage only.',
error,
);
const cfi = progress.location;
if (!view || !cfi) return null;
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index: spineIndex } = content;
const converter = new XCFI(doc, spineIndex || 0);
const xpointerResult = converter.cfiToXPointer(cfi);
koProgress = normalizeProgressXPointer(xpointerResult.xpointer);
}
} catch (error) {
console.error('Failed to convert CFI to XPointer', error);
}
const page = currentProgress.pageinfo?.current ?? 0;
const totalPages = currentProgress.pageinfo?.total ?? 0;
const page = progress.pageinfo?.current ?? 0;
const totalPages = progress.pageinfo?.total ?? 0;
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
}
return { progressStr, percentage };
return { koProgress, percentage };
}, [bookKey, getProgress, getBookData, getView]);
const applyRemoteProgress = async (book: Book, bookDoc: BookDoc, remote: KoSyncProgress) => {
const view = getView(bookKey);
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress!, 10);
if (isNaN(pageToGo)) return;
view?.select(pageToGo - 1);
} else {
if (!remote.progress?.startsWith('/body')) return;
try {
const content = view?.renderer.getContents()[0];
const cfi = await getCFIFromXPointer(
remote.progress!,
content?.doc,
content?.index,
bookDoc,
);
view?.goTo(cfi);
} catch (error) {
console.error('Failed to convert XPointer to CFI', error);
return;
}
}
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
};
const promptedSync = async (
book: Book,
bookDoc: BookDoc,
local: BookProgress,
remote: KoSyncProgress,
) => {
let localPreview = '';
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const localPageInfo = local.section;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = localPageInfo
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: localPageInfo.current + 1,
total: localPageInfo.total,
percentage: localPercentage,
})
: _('Current position');
const remotePage = parseInt(remote.progress!, 10);
if (!isNaN(remotePage) && remotePercentage > 0) {
const localTotalPages = localPageInfo?.total ?? 0;
const remoteTotalPages = Math.round(remotePage / remotePercentage);
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
if (pagesMatch) {
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
} else {
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
}
} else {
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
} else {
const localPageInfo = local.pageinfo;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = `${local.sectionLabel} (${localPercentage}%)`;
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
setConflictDetails({
book,
bookDoc,
local: { cfi: local.location, preview: localPreview },
remote: { ...remote, preview: remotePreview },
});
};
const pushProgress = useMemo(
() =>
debounce(async () => {
const { settings: currentSettings } = useSettingsStore.getState();
if (!bookKey || !appService || !kosyncClient || !hasPulledOnce.current) return;
const { settings } = useSettingsStore.getState();
if (['receive', 'disable'].includes(settings.kosync.strategy)) return;
const currentBook = getBookData(bookKey)?.book;
const progress = generateKOProgress();
if (!currentBook || !progress || !progress.koProgress) return;
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = currentSettings;
if (
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
['receive', 'disable'].includes(koreaderSyncStrategy) ||
!currentBook
)
return;
const getDocumentDigest = (bookToDigest: Book): string => {
if (currentSettings.koreaderSyncChecksumMethod === 'filename') {
const filename = bookToDigest.sourceTitle || bookToDigest.title;
const normalizedPath = filename.replace(/\\/g, '/');
return md5(
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath,
);
}
return bookToDigest.hash;
};
const getDeviceName = async () => {
if (currentSettings.koreaderSyncDeviceName) return currentSettings.koreaderSyncDeviceName;
if (appService?.appPlatform === 'tauri') {
const name = await osType();
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
}
return 'Readest';
};
const digest = getDocumentDigest(currentBook);
const progressData = mapProgressToServerFormat();
if (!digest || !progressData) return;
if (progressData.progressStr === lastPushedCfiRef.current) return;
const deviceName = await getDeviceName();
const client = new KOSyncClient(
currentSettings.koreaderSyncServerUrl,
currentSettings.koreaderSyncUsername,
currentSettings.koreaderSyncUserkey,
currentSettings.koreaderSyncChecksumMethod,
currentSettings.koreaderSyncDeviceId,
deviceName,
);
await client.updateProgress(currentBook, progressData.progressStr, progressData.percentage);
lastPushedCfiRef.current = progressData.progressStr;
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
}, 5000),
[bookKey, appService, getBookData, mapProgressToServerFormat],
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, appService, kosyncClient],
);
useEffect(() => {
const handleFlush = (event: CustomEvent) => {
const { bookKey: syncBookKey } = event.detail;
if (syncBookKey === bookKey) {
pushProgress.flush();
}
};
eventDispatcher.on('flush-koreader-sync', handleFlush);
return () => {
eventDispatcher.off('flush-koreader-sync', handleFlush);
pushProgress.flush();
};
}, [bookKey, pushProgress]);
const pullProgress = useCallback(
async () => {
if (!progress?.location || !appService || !kosyncClient) return;
useEffect(() => {
const performInitialSync = async () => {
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = settings;
if (
!book ||
!bookDoc ||
!progress ||
!koreaderSyncUsername ||
!koreaderSyncUserkey ||
koreaderSyncStrategy === 'disabled'
)
return;
const bookData = getBookData(bookKey);
const book = bookData?.book;
const bookDoc = bookData?.bookDoc;
if (!book || !bookDoc) return;
if (koreaderSyncStrategy === 'send') {
syncCompletedForKey.current = bookKey;
const { strategy, enabled } = settings.kosync;
if (!enabled) return;
hasPulledOnce.current = true;
if (strategy === 'send') {
setSyncState('synced');
return;
}
setSyncState('checking');
const getDeviceName = async () => {
if (settings.koreaderSyncDeviceName) return settings.koreaderSyncDeviceName;
if (appService?.appPlatform === 'tauri') {
const name = await osType();
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
}
return 'Readest';
};
const deviceName = await getDeviceName();
const client = new KOSyncClient(
settings.koreaderSyncServerUrl,
settings.koreaderSyncUsername,
settings.koreaderSyncUserkey,
settings.koreaderSyncChecksumMethod,
settings.koreaderSyncDeviceId,
deviceName,
);
const remote = await client.getProgress(book);
lastPushedCfiRef.current = progress.location;
if (!remote?.progress || !remote?.timestamp) {
syncCompletedForKey.current = bookKey;
const remoteProgress = await kosyncClient.getProgress(book);
if (!remoteProgress || !remoteProgress.progress || !remoteProgress.timestamp) {
setSyncState('synced');
if (settings.koreaderSyncStrategy !== 'receive') {
pushProgress();
pushProgress.flush();
}
return;
}
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
const remoteIsNewer = remote.timestamp * 1000 > localTimestamp;
const localIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
? progress.section?.current.toString()
: progress.location;
const isLocalCFI = localIdentifier?.startsWith('epubcfi');
const remoteIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
? (parseInt(remote.progress, 10) - 1).toString()
: remote.progress.startsWith('epubcfi')
? remote.progress
: null;
const isRemoteCFI = remoteIdentifier?.startsWith('epubcfi');
let isProgressIdentical = false;
if (isLocalCFI && isRemoteCFI) {
isProgressIdentical = localIdentifier === remoteIdentifier;
}
if (!isProgressIdentical) {
const localPercentage = mapProgressToServerFormat()?.percentage ?? 0;
const remotePercentage = remote.percentage;
if (remotePercentage !== undefined && remotePercentage !== null) {
const tolerance = settings.koreaderSyncPercentageTolerance;
const percentageDifference = Math.abs(localPercentage - remotePercentage);
isProgressIdentical = percentageDifference < tolerance;
}
}
if (isProgressIdentical) {
lastPushedCfiRef.current = localIdentifier;
syncCompletedForKey.current = bookKey;
const remoteIsNewer = remoteProgress.timestamp * 1000 > localTimestamp;
if (strategy === 'receive' || (strategy === 'silent' && remoteIsNewer)) {
applyRemoteProgress(book, bookDoc, remoteProgress);
setSyncState('synced');
return;
}
if (
settings.koreaderSyncStrategy === 'receive' ||
(settings.koreaderSyncStrategy === 'silent' && remoteIsNewer)
) {
const applyRemoteProgress = async () => {
const view = getView(bookKey);
if (view && remote.progress) {
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const pageToGo = parseInt(remote.progress, 10);
if (!isNaN(pageToGo)) view.select(pageToGo - 1);
} else {
const isXPointer = remote.progress.startsWith('/body');
if (isXPointer) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index } = content;
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
view.goTo(cfi);
eventDispatcher.dispatch('toast', {
message: _('Reading Progress Synced'),
type: 'info',
});
}
} catch (error) {
console.error(
'Failed to convert XPointer to CFI, falling back to percentage.',
error,
);
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
} else {
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
}
eventDispatcher.dispatch('toast', {
message: _('Reading Progress Synced'),
type: 'info',
});
}
};
applyRemoteProgress();
syncCompletedForKey.current = bookKey;
setSyncState('synced');
} else if (settings.koreaderSyncStrategy === 'prompt') {
let localPreview = '';
let remotePreview = '';
const remotePercentage = remote.percentage || 0;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
const localPageInfo = progress.section;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = localPageInfo
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: localPageInfo.current + 1,
total: localPageInfo.total,
percentage: localPercentage,
})
: _('Current position');
const remotePage = parseInt(remote.progress, 10);
if (!isNaN(remotePage) && remotePercentage > 0) {
const localTotalPages = localPageInfo?.total ?? 0;
const remoteTotalPages = Math.round(remotePage / remotePercentage);
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
if (pagesMatch) {
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
} else {
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
page: remotePage,
total: remoteTotalPages,
percentage: Math.round(remotePercentage * 100),
});
}
} else {
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
} else {
const localPageInfo = progress.pageinfo;
const localPercentage =
localPageInfo && localPageInfo.total > 0
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
: 0;
localPreview = `${progress.sectionLabel} (${localPercentage}%)`;
remotePreview = _('Approximately {{percentage}}%', {
percentage: Math.round(remotePercentage * 100),
});
}
setConflictDetails({
book,
bookDoc,
local: { cfi: progress.location, preview: localPreview },
remote: { ...remote, preview: remotePreview, percentage: remote.percentage },
});
} else if (strategy === 'prompt') {
promptedSync(book, bookDoc, progress, remoteProgress);
setSyncState('conflict');
} else {
syncCompletedForKey.current = bookKey;
setSyncState('synced');
}
};
if (bookKey && book && progress && syncCompletedForKey.current !== bookKey) {
syncCompletedForKey.current = bookKey;
performInitialSync();
}
}, [
bookKey,
book,
bookDoc,
progress,
settings,
appService,
getBookData,
getProgress,
getView,
mapProgressToServerFormat,
pushProgress,
_,
bookData?.config?.updatedAt,
]);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, appService, kosyncClient, settings.kosync, progress],
);
useEffect(() => {
const handlePushProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pushProgress();
};
const handleFlush = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pushProgress.flush();
};
eventDispatcher.on('push-kosync', handlePushProgress);
eventDispatcher.on('flush-kosync', handleFlush);
return () => {
eventDispatcher.off('push-kosync', handlePushProgress);
eventDispatcher.off('flush-kosync', handleFlush);
pushProgress.flush();
};
}, [bookKey, pushProgress]);
useEffect(() => {
const handlePullProgress = (event: CustomEvent) => {
if (event.detail.bookKey !== bookKey) return;
pullProgress();
};
eventDispatcher.on('pull-kosync', handlePullProgress);
return () => {
eventDispatcher.off('pull-kosync', handlePullProgress);
};
}, [bookKey, pullProgress]);
// Pull: pull progress once when the book is opened
useEffect(() => {
if (!appService || !kosyncClient || !progress?.location) return;
if (hasPulledOnce.current) return;
pullProgress();
}, [appService, kosyncClient, progress?.location, pushProgress, pullProgress]);
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (syncState === 'synced' && progress) {
if (
settings.koreaderSyncStrategy !== 'receive' &&
settings.koreaderSyncStrategy !== 'disabled'
) {
const { strategy, enabled } = settings.kosync;
if (strategy !== 'receive' && enabled) {
pushProgress();
}
}
}, [progress, syncState, settings.koreaderSyncStrategy, pushProgress]);
useEffect(() => {
return () => {
pushProgress.flush();
};
}, [pushProgress]);
}, [progress, syncState, settings.kosync, pushProgress]);
const resolveConflictWithLocal = () => {
pushProgress();
@@ -421,50 +295,12 @@ export const useKOSync = (bookKey: string) => {
const resolveConflictWithRemote = async () => {
const view = getView(bookKey);
const remote = conflictDetails?.remote;
const currentBook = conflictDetails?.book;
const book = conflictDetails?.book;
const bookDoc = conflictDetails?.bookDoc;
if (view && remote?.progress && currentBook) {
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
const localTotalPages = getProgress(bookKey)?.section?.total ?? 0;
const remotePage = parseInt(remote.progress, 10);
const remotePercentage = remote.percentage || 0;
const remoteTotalPages =
remotePercentage > 0 ? Math.round(remotePage / remotePercentage) : 0;
if (!book || !bookDoc || !remote || !remote.progress || !view) return;
if (!isNaN(remotePage) && Math.abs(localTotalPages - remoteTotalPages) <= 1) {
console.log('Going to remote page:', remotePage);
view.select(remotePage - 1);
} else if (remote.percentage !== undefined && remote.percentage !== null) {
console.log('Going to remote percentage:', remote.percentage);
view.goToFraction(remote.percentage);
}
} else {
const isXPointer = remote.progress.startsWith('/body');
const isCFI = remote.progress.startsWith('epubcfi');
if (isXPointer) {
try {
const content = view.renderer.getContents()[0];
if (content) {
const { doc, index } = content;
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
view.goTo(cfi);
}
} catch (error) {
console.error('Failed to convert XPointer to CFI, falling back to percentage.', error);
if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
} else if (isCFI) {
view.goTo(remote.progress);
} else if (remote.percentage !== undefined && remote.percentage !== null) {
view.goToFraction(remote.percentage);
}
}
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
}
applyRemoteProgress(book, bookDoc, remote);
setSyncState('synced');
setConflictDetails(null);
};
@@ -473,8 +309,9 @@ export const useKOSync = (bookKey: string) => {
syncState,
conflictDetails,
errorMessage,
pushProgress,
pullProgress,
resolveConflictWithLocal,
resolveConflictWithRemote,
pushProgress,
};
};
@@ -33,14 +33,14 @@ export const useNotesSync = (bookKey: string) => {
const newNotes = getNewNotes();
syncNotes(newNotes, bookHash, 'both');
}, SYNC_NOTES_INTERVAL_SEC * 1000),
[lastSyncedAtNotes],
[syncNotes],
);
useEffect(() => {
if (!config?.location || !user) return;
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
}, [config, handleAutoSync]);
useEffect(() => {
const processNewNote = (note: BookNote) => {
@@ -11,7 +11,7 @@ import { CFI } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
import { getCFIFromXPointer, getXPointerFromCFI, normalizeProgressXPointer } from '@/utils/xcfi';
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
@@ -56,7 +56,7 @@ export const useProgressSync = (bookKey: string) => {
if (content && !FIXED_LAYOUT_FORMATS.has(book.format)) {
const { doc, index } = content;
const xpointerResult = await getXPointerFromCFI(config.location!, doc, index || 0);
config.xpointer = xpointerResult.xpointer;
config.xpointer = normalizeProgressXPointer(xpointerResult.xpointer);
}
} catch (error) {
console.warn('Failed to convert CFI to XPointer', error);
+6 -4
View File
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
@@ -58,6 +59,7 @@ const ProfilePage = () => {
const { envConfig, appService } = useEnv();
const { token, user, logout } = useAuth();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [availablePlans, setAvailablePlans] = useState<AvailablePlan[]>([]);
@@ -345,10 +347,10 @@ const ProfilePage = () => {
)}
>
<div
className={clsx(
'flex h-full w-full flex-col items-center overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
className={clsx('flex h-full w-full flex-col items-center overflow-y-auto')}
style={{
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<ProfileHeader onGoBack={handleGoBack} />
<div className='w-full min-w-60 max-w-4xl py-10'>
+2 -2
View File
@@ -41,7 +41,7 @@ const Dialog: React.FC<DialogProps> = ({
onClose,
}) => {
const { appService } = useEnv();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { systemUIVisible, statusBarHeight, safeAreaInsets } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(!snapHeight);
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
@@ -179,7 +179,7 @@ const Dialog: React.FC<DialogProps> = ({
style={{
paddingTop:
appService?.hasSafeAreaInset && isFullHeightInMobile
? `max(env(safe-area-inset-top), ${systemUIVisible ? statusBarHeight : 0}px)`
? `${Math.max(safeAreaInsets?.top || 0, systemUIVisible ? statusBarHeight : 0)}px`
: '0px',
...(isMobile ? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 } : {}),
}}
@@ -11,7 +11,7 @@ const ModalPortal: React.FC<ModalPortalProps> = ({ children, showOverlay = true
return ReactDOM.createPortal(
<div
className={clsx(
'fixed inset-0 isolate z-50 flex items-center justify-center',
'fixed inset-0 isolate z-[100] flex items-center justify-center',
showOverlay && 'bg-black bg-opacity-50',
)}
style={{ transform: 'translateZ(0)' }}
@@ -6,12 +6,14 @@ import { AuthProvider } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import { SyncProvider } from '@/context/SyncContext';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { initSystemThemeListener } from '@/store/themeStore';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { appService } = useEnv();
const iconSize = useDefaultIconSize();
useSafeAreaInsets(); // Initialize safe area insets
useEffect(() => {
if (appService) {
+6 -4
View File
@@ -1,19 +1,21 @@
import clsx from 'clsx';
import React from 'react';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
const Spinner: React.FC<{
loading: boolean;
}> = ({ loading }) => {
const _ = useTranslation();
const { safeAreaInsets } = useThemeStore();
if (!loading) return null;
return (
<div
className={clsx(
'absolute left-1/2 -translate-x-1/2 transform text-center',
'top-4 pt-[calc(env(safe-area-inset-top)+64px)]',
)}
className={clsx('absolute left-1/2 top-4 -translate-x-1/2 transform text-center')}
style={{
paddingTop: `${(safeAreaInsets?.top || 0) + 64}px`,
}}
role='status'
>
<span className='loading loading-dots loading-lg'></span>
+7 -2
View File
@@ -1,10 +1,12 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { useThemeStore } from '@/store/themeStore';
import { eventDispatcher } from '@/utils/event';
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export const Toast = () => {
const { safeAreaInsets } = useThemeStore();
const [toastMessage, setToastMessage] = useState('');
const toastType = useRef<ToastType>('info');
const toastTimeout = useRef(5000);
@@ -52,9 +54,12 @@ export const Toast = () => {
className={clsx(
'toast toast-center toast-middle z-50 w-auto max-w-screen-sm',
toastClassMap[toastType.current],
toastClassMap[toastType.current].includes('toast-top') &&
'top-[calc(44px+env(safe-area-inset-top))]',
)}
style={{
top: toastClassMap[toastType.current].includes('toast-top')
? `${(safeAreaInsets?.top || 0) + 44}px`
: undefined,
}}
>
<div
className={clsx(
@@ -7,9 +7,8 @@ import { BookMetadata } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { flattenContributors, formatAuthors, formatPublisher, formatTitle } from '@/utils/book';
import { useFileSelector } from '@/hooks/useFileSelector';
import { FormField } from './FormField';
import { IMAGE_ACCEPT_FORMATS, SUPPORTED_IMAGE_EXTS } from '@/services/constants';
import { isTauriAppPlatform } from '@/services/environment';
import BookCover from '@/components/BookCover';
interface BookDetailEditProps {
@@ -47,6 +46,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { selectFiles } = useFileSelector(appService, _);
const hasLockedFields = Object.values(lockedFields).some((locked) => locked);
const allFieldsLocked = Object.values(lockedFields).every((locked) => locked);
@@ -148,51 +148,23 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
},
];
const selectImageFileWeb = () => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = IMAGE_ACCEPT_FORMATS;
fileInput.multiple = false;
fileInput.click();
fileInput.onchange = () => {
resolve(fileInput.files);
};
});
};
const selectImageFileTauri = async () => {
const exts = appService?.isMobileApp ? [] : SUPPORTED_IMAGE_EXTS;
const files = (await appService?.selectFiles(_('Select Cover Image'), exts)) || [];
if (appService?.isIOSApp) {
return files.filter((file) => {
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
return SUPPORTED_IMAGE_EXTS.includes(fileExt);
});
}
return files;
};
const handleSelectLocalImage = async () => {
let files;
if (isTauriAppPlatform()) {
files = (await selectImageFileTauri()) as string[];
if (appService && files.length > 0) {
metadata.coverImageFile = files[0]!;
selectFiles({ type: 'covers', multiple: false }).then(async (result) => {
if (result.error || result.files.length === 0) return;
const selectedFile = result.files[0]!;
if (selectedFile.path && appService) {
const filePath = selectedFile.path;
metadata.coverImageFile = filePath;
const tempName = `cover-${Date.now()}.png`;
const cachePrefix = await appService.fs.getPrefix('Cache');
await appService.fs.copyFile(files[0]!, tempName, 'Cache');
await appService.fs.copyFile(filePath, tempName, 'Cache');
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
setNewCoverImageUrl(metadata.coverImageUrl!);
}
} else {
files = (await selectImageFileWeb()) as File[];
if (files.length > 0) {
metadata.coverImageBlobUrl = URL.createObjectURL(files[0]!);
} else if (selectedFile.file) {
metadata.coverImageBlobUrl = URL.createObjectURL(selectedFile.file);
setNewCoverImageUrl(metadata.coverImageBlobUrl!);
}
}
});
};
return (
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react';
import { Book } from '@/types/book';
import { BookMetadata } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useMetadataEdit } from './useMetadataEdit';
@@ -45,6 +46,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
handleBookMetadataUpdate,
}) => {
const _ = useTranslation();
const { safeAreaInsets } = useThemeStore();
const [loading, setLoading] = useState(false);
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
const [editMode, setEditMode] = useState(false);
@@ -239,10 +241,10 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
{activeDeleteAction && currentDeleteConfig && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
>
<Alert
title={currentDeleteConfig.title}
@@ -0,0 +1,148 @@
import { AppService } from '@/types/system';
import { isTauriAppPlatform } from '@/services/environment';
import { basename } from '@tauri-apps/api/path';
import { stubTranslation as _ } from '@/utils/misc';
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
export interface FileSelectorOptions {
type: SelectionType;
accept?: string;
multiple?: boolean;
extensions?: string[];
dialogTitle?: string;
}
export interface SelectedFile {
// For Web file
file?: File;
// For Tauri file
path?: string;
}
export interface FileSelectionResult {
files: SelectedFile[];
error?: string;
}
const selectFileWeb = (options: FileSelectorOptions): Promise<File[]> => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = options.accept || '*/*';
fileInput.multiple = options.multiple || false;
fileInput.click();
fileInput.onchange = () => {
resolve(Array.from(fileInput.files || []));
};
});
};
const selectFileTauri = async (
options: FileSelectorOptions,
appService: AppService,
_: (key: string) => string,
): Promise<string[]> => {
const noFilter = appService?.isIOSApp || (appService?.isAndroidApp && options.type === 'books');
const exts = noFilter ? [] : options.extensions || [];
const title = options.dialogTitle || _('Select Files');
let files = (await appService?.selectFiles(_(title), exts)) || [];
if (noFilter && options.extensions) {
files = await Promise.all(
files.map(async (file: string) => {
let processedFile = file;
if (appService?.isAndroidApp && file.startsWith('content://')) {
processedFile = await basename(file);
}
const fileExt = processedFile.split('.').pop()?.toLowerCase() || 'unknown';
const extensions = options.extensions!;
const shouldInclude = extensions.includes(fileExt) || extensions.includes('*');
return shouldInclude ? file : null;
}),
).then((results) => results.filter((file) => file !== null));
}
return files;
};
const processWebFiles = (files: File[]): SelectedFile[] => {
return files.map((file) => ({
file,
}));
};
const processTauriFiles = (files: string[]): SelectedFile[] => {
return files.map((path) => ({
path,
}));
};
export const useFileSelector = (appService: AppService | null, _: (key: string) => string) => {
const selectFiles = async (options: FileSelectorOptions = { type: 'generic' }) => {
options = { ...FILE_SELECTION_PRESETS[options.type], ...options };
if (!appService) {
return { files: [] as SelectedFile[], error: 'App service is not available' };
}
try {
if (isTauriAppPlatform()) {
const filePaths = await selectFileTauri(options, appService, _);
const files = await processTauriFiles(filePaths);
return { files };
} else {
const webFiles = await selectFileWeb(options);
const files = processWebFiles(webFiles);
return { files };
}
} catch (error) {
return {
files: [],
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
};
return {
selectFiles,
};
};
export const FILE_SELECTION_PRESETS = {
generic: {
accept: '*/*',
extensions: ['*'],
dialogTitle: _('Select Files'),
},
images: {
accept: 'image/*',
extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
dialogTitle: _('Select Image'),
},
videos: {
accept: 'video/*',
extensions: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'],
dialogTitle: _('Select Video'),
},
audio: {
accept: 'audio/*',
extensions: ['mp3', 'wav', 'ogg', 'flac', 'm4a'],
dialogTitle: _('Select Audio'),
},
books: {
accept: BOOK_ACCEPT_FORMATS,
extensions: SUPPORTED_BOOK_EXTS,
dialogTitle: _('Select Books'),
},
fonts: {
accept: '.ttf, .otf, .woff, .woff2',
extensions: ['ttf', 'otf', 'woff', 'woff2'],
dialogTitle: _('Select Fonts'),
},
covers: {
accept: '.png, .jpg, .jpeg, .gif',
extensions: ['png', 'jpg', 'jpeg', 'gif'],
dialogTitle: _('Select Image'),
},
};
export type SelectionType = keyof typeof FILE_SELECTION_PRESETS;
+46 -10
View File
@@ -1,6 +1,10 @@
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { getSafeAreaInsets } from '@/utils/bridge';
import { useState, useEffect, useCallback } from 'react';
export const useSafeAreaInsets = () => {
const { appService } = useEnv();
const [updated, setUpdated] = useState(false);
const [insets, setInsets] = useState({
top: 0,
@@ -9,27 +13,59 @@ export const useSafeAreaInsets = () => {
left: 0,
});
const updateSafeAreaInsets = useCallback(() => {
const { updateSafeAreaInsets } = useThemeStore();
const onUpdateInsets = useCallback(() => {
if (!appService) return;
if (!appService.hasSafeAreaInset) {
updateSafeAreaInsets(insets);
setUpdated(true);
return;
}
const rootStyles = getComputedStyle(document.documentElement);
const hasCustomProperties = rootStyles.getPropertyValue('--safe-area-inset-top');
if (hasCustomProperties) {
setInsets({
const isWebView139 = /Chrome\/139/.test(navigator.userAgent);
// safe-area-inset-* values in css are always 0px in some versions of webview 139
// due to https://issues.chromium.org/issues/40699457
if (appService.isAndroidApp && isWebView139) {
getSafeAreaInsets().then((response) => {
if (response.error) {
console.error('Error getting safe area insets from native bridge:', response.error);
} else {
const insets = {
top: response.top,
right: response.right,
bottom: response.bottom,
left: response.left,
};
setInsets(insets);
updateSafeAreaInsets(insets);
setUpdated(true);
}
});
} else if (hasCustomProperties) {
const insets = {
top: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-top')) || 0,
right: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-right')) || 0,
bottom: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-bottom')) || 0,
left: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-left')) || 0,
});
};
setInsets(insets);
updateSafeAreaInsets(insets);
setUpdated(true);
}
setUpdated(true);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
useEffect(() => {
updateSafeAreaInsets();
window.addEventListener('resize', updateSafeAreaInsets);
onUpdateInsets();
window.addEventListener('resize', onUpdateInsets);
return () => {
window.removeEventListener('resize', updateSafeAreaInsets);
window.removeEventListener('resize', onUpdateInsets);
};
}, [updateSafeAreaInsets]);
}, [onUpdateInsets]);
return updated ? insets : null;
};
+61 -37
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useSyncContext } from '@/context/SyncContext';
import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync';
@@ -47,7 +47,7 @@ export function useSync(bookKey?: string) {
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(0);
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(0);
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(0);
const lastSyncedAtInited = useRef(false);
const [lastSyncedAtInited, setLastSyncedAtInited] = useState(false);
const [syncing, setSyncing] = useState(false);
// null means unsynced, empty array means synced no changes
@@ -63,17 +63,19 @@ export function useSync(bookKey?: string) {
const { syncClient } = useSyncContext();
useEffect(() => {
if (!settings || !config) return;
if (lastSyncedAtInited.current) return;
lastSyncedAtInited.current = true;
if (!settings.version) return;
if (bookKey && !config?.location) return;
if (lastSyncedAtInited) return;
const lastSyncedBooksAt = settings.lastSyncedAtBooks ?? 0;
const lastSyncedConfigsAt = config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0;
const lastSyncedNotesAt = config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0;
setLastSyncedAtBooks(lastSyncedBooksAt > 0 ? lastSyncedBooksAt - SEVEN_DAYS_IN_MS : 0);
setLastSyncedAtBooks(lastSyncedBooksAt > 0 ? lastSyncedBooksAt : 0);
setLastSyncedAtConfigs(lastSyncedConfigsAt > 0 ? lastSyncedConfigsAt - SEVEN_DAYS_IN_MS : 0);
setLastSyncedAtNotes(lastSyncedNotesAt > 0 ? lastSyncedNotesAt - SEVEN_DAYS_IN_MS : 0);
}, [settings, config]);
setLastSyncedAtInited(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey, settings, config]);
// bookId is for configs and notes only, if bookId is provided, only pull changes for that book
// and update the lastSyncedAt for that book in the book config
@@ -158,38 +160,60 @@ export function useSync(bookKey?: string) {
}
};
const syncBooks = async (books?: Book[], op: SyncOp = 'both') => {
if ((op === 'push' || op === 'both') && books?.length) {
await pushChanges({ books });
}
if (op === 'pull' || op === 'both') {
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
}
};
const syncBooks = useCallback(
async (books?: Book[], op: SyncOp = 'both') => {
if (!lastSyncedAtInited) return;
if ((op === 'push' || op === 'both') && books?.length) {
await pushChanges({ books });
}
if (op === 'pull' || op === 'both') {
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastSyncedAtInited, lastSyncedAtBooks],
);
const syncConfigs = async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
await pushChanges({ configs: bookConfigs });
}
if (op === 'pull' || op === 'both') {
await pullChanges(
'configs',
lastSyncedAtConfigs,
setLastSyncedAtConfigs,
setSyncingConfigs,
bookId,
);
}
};
const syncConfigs = useCallback(
async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
if (!bookId && !lastSyncedAtInited) return;
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
await pushChanges({ configs: bookConfigs });
}
if (op === 'pull' || op === 'both') {
await pullChanges(
'configs',
lastSyncedAtConfigs,
setLastSyncedAtConfigs,
setSyncingConfigs,
bookId,
);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastSyncedAtInited, lastSyncedAtConfigs],
);
const syncNotes = async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
if ((op === 'push' || op === 'both') && bookNotes?.length) {
await pushChanges({ notes: bookNotes });
}
if (op === 'pull' || op === 'both') {
await pullChanges('notes', lastSyncedAtNotes, setLastSyncedAtNotes, setSyncingNotes, bookId);
}
};
const syncNotes = useCallback(
async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
if (!lastSyncedAtInited) return;
if ((op === 'push' || op === 'both') && bookNotes?.length) {
await pushChanges({ notes: bookNotes });
}
if (op === 'pull' || op === 'both') {
await pullChanges(
'notes',
lastSyncedAtNotes,
setLastSyncedAtNotes,
setSyncingNotes,
bookId,
);
}
return true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastSyncedAtInited, lastSyncedAtNotes],
);
useEffect(() => {
if (!syncing && syncResult) {
+2 -2
View File
@@ -38,7 +38,7 @@ export const useTheme = ({
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService?.isAndroidApp]);
const handleSystemUIVisibility = useCallback(() => {
if (!appService?.isMobileApp) return;
@@ -94,7 +94,7 @@ export const useTheme = ({
applyCustomTheme(customTheme);
});
localStorage.setItem('customThemes', JSON.stringify(customThemes));
}, [settings]);
}, [settings.globalReadSettings?.customThemes]);
useEffect(() => {
const colorScheme = isDarkMode ? 'dark' : 'light';
+13 -1
View File
@@ -101,6 +101,8 @@ export const EXTS: Record<BookFormat, string> = {
EPUB: 'epub',
PDF: 'pdf',
MOBI: 'mobi',
AZW: 'azw',
AZW3: 'azw3',
CBZ: 'cbz',
FB2: 'fb2',
FBZ: 'fbz',
@@ -228,7 +230,17 @@ export class DocumentLoader {
const fflate = await import('foliate-js/vendor/fflate.js');
const { MOBI } = await import('foliate-js/mobi.js');
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(this.file);
format = 'MOBI';
const ext = this.file.name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'azw':
format = 'AZW';
break;
case 'azw3':
format = 'AZW3';
break;
default:
format = 'MOBI';
}
} else if (this.isFB2()) {
const { makeFB2 } = await import('foliate-js/fb2.js');
book = await makeFB2(this.file);
+6 -5
View File
@@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { KoSyncProxyPayload } from '@/types/kosync';
const validEndpoints = [/\/users\/create/, /\/users\/auth/, /\/syncs\/progress/];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
@@ -21,6 +23,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ error: 'serverUrl and endpoint are required' });
}
if (!validEndpoints.some((regex) => regex.test(endpoint))) {
return res.status(400).json({ error: 'Invalid endpoint' });
}
const targetUrl = `${serverUrl.replace(/\/$/, '')}${endpoint}`;
try {
@@ -34,11 +40,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
body: clientBody ? JSON.stringify(clientBody) : null,
});
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Invalid sync server response: Unexpected Content-Type.');
}
const data = await response.text();
res.status(response.status);
try {
@@ -1,7 +1,11 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createSupabaseClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { getStoragePlanData, validateUserAndToken } from '@/utils/access';
import {
getStoragePlanData,
validateUserAndToken,
STORAGE_QUOTA_GRACE_BYTES,
} from '@/utils/access';
import { getUploadSignedUrl } from '@/utils/object';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -23,7 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const { usage, quota } = getStoragePlanData(token);
if (usage + fileSize > quota) {
if (usage + fileSize > quota + STORAGE_QUOTA_GRACE_BYTES) {
return res.status(403).json({ error: 'Insufficient storage quota', usage });
}
+1 -1
View File
@@ -239,7 +239,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
response = await POST(nextReq);
} else {
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).end(`Method ${req.method} Not Allowed`);
return res.status(405).json({ error: 'Method Not Allowed' });
}
res.status(response.status);

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