Compare commits

...

70 Commits

Author SHA1 Message Date
Huang Xin cc3cc58de0 release: version 0.9.78 (#1975) 2025-09-06 10:50:18 +02:00
Huang Xin 80d60266c2 settings: default to override background color in fixed layout documents (#1974) 2025-09-06 10:40:18 +02:00
54wedge f06cdb5899 txt: nest chapters inside volumes when converting to epub (#1972)
* txt: nest chapters inside volumes

* txt: readability

* txt: format
2025-09-06 10:28:26 +02:00
Huang Xin dfe68988aa fix(kosync): use 1-based indices in xpointer, closes #1968 (#1973) 2025-09-06 07:40:32 +02:00
Huang Xin d0264de3eb compat: more accessible iframes for PDFs, closes #1136 (#1971) 2025-09-05 18:56:15 +02:00
Huang Xin 77c4303938 feat(pdf): support setting zoom level, zoom mode and spread mode for PDF (#1969)
This should close #1717, close #1276, close #669.
2025-09-05 18:20:15 +02:00
Huang Xin 5724d2eeec feat(tts): support going previous/next by sentence in bottom TTS bar, closes #1816 (#1967) 2025-09-05 10:34:30 +02:00
54wedge 64007b1188 txt: improve zh chapter extraction (#1966)
* txt: improve zh chapter extraction

* txt: fix regexp

* txt: one more keyword
2025-09-05 10:31:26 +02:00
Huang Xin 50ee7bd8be doc: change badge colors for last commit and commit activity 2025-09-04 11:44:11 +02:00
Huang Xin 2bcbde4bf1 doc: fixed badge-language-coverage URL encoding 2025-09-04 11:39:27 +02:00
Huang Xin 99f75dd3b6 i18n: add Bengali(BN), Sinhala (SI) and Tamil (TA) translations (#1965) 2025-09-04 11:37:29 +02:00
Huang Xin 2b9132b4a8 feat(translator): preprocess some common mistranslated publishing terms (#1964) 2025-09-04 09:38:17 +02:00
Huang Xin 82782be16e fix: prevent layout shift when virtual keyboard pops up on Android, closes #1959 (#1963) 2025-09-04 08:04:51 +02:00
Huang Xin 7067129887 fix(tts): fixed headings cannot be highlighted in TTS (#1955) 2025-09-03 16:42:59 +02:00
Huang Xin 6e706ac8f7 feat: add inline editor for bookmarks in the sidebar, closes #1909 (#1954) 2025-09-03 11:06:35 +02:00
Huang Xin 39b902c927 epub: parse correct book front cover for some EPUBs, closes #1913 (#1953) 2025-09-03 07:50:26 +02:00
Huang Xin 94a9cd7c9d doc: update README and add link of readest subreddit (#1952) 2025-09-03 07:24:54 +02:00
Huang Xin 0dae7db140 fix: improved sensitivity of text selection with stylus, closes #706 (#1950) 2025-09-02 18:51:45 +02:00
Huang Xin 32954e025c fix(tts): media control in the lock screen and with airpods for iOS, closes #1407 (#1949) 2025-09-02 17:24:16 +02:00
Huang Xin 61dda9a517 feat: group custom fonts into families, closes #1934 (#1945) 2025-09-01 17:49:46 +02:00
Huang Xin 99a4cb50ac feat: add an option to show persistent TTS bar at the bottom, closes #1818 (#1944) 2025-09-01 14:23:28 +02:00
Huang Xin f4b00d6dec fix: get rid of invalid grid insets (#1943) 2025-09-01 07:05:48 +02:00
Huang Xin f69422a61c fix: conditional Back key interception on Android (#1942) 2025-09-01 06:16:31 +02:00
Huang Xin 7f4c157a90 chore: build with install package permission by default for Android (#1939) 2025-08-31 16:53:29 +02:00
Huang Xin 7d9724ff8b fix: close books with the system back key on Android, closes #1933 (#1938) 2025-08-31 10:15:28 +02:00
Huang Xin 06db7f36f8 refactor: rename ConfirmSyncDialog to KOSyncResolver (#1937) 2025-08-31 05:46:43 +02:00
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
157 changed files with 6435 additions and 2250 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 -40
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'
@@ -177,11 +210,6 @@ jobs:
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
MANIFEST="src-tauri/gen/android/app/src/main/AndroidManifest.xml"
PERMISSION_LINE='<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>'
grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST" || \
sed -i "/android.permission.INTERNET/a \ $PERMISSION_LINE" "$MANIFEST"
pushd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
@@ -288,41 +316,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" }
+24 -19
View File
@@ -11,14 +11,15 @@
[![Web App][badge-web-app]][link-web-readest]
[![OS][badge-platforms]][link-website]
<br>
[![][badge-hellogithub]][link-hellogithub]
[![][badge-discord]][link-discord]
[![Discord][badge-discord]][link-discord]
[![Reddit][badge-reddit]][link-reddit]
[![AGPL Licence][badge-license]](LICENSE)
[![Latest release][badge-release]][link-gh-releases]
[![Language Coverage][badge-language-coverage]]()
[![Donate][badge-donate]][link-donate]
<br>
[![Latest release][badge-release]][link-gh-releases]
[![Last commit][badge-last-commit]][link-gh-commits]
[![Commits][badge-commit-activity]][link-gh-pulse]
[![][badge-hellogithub]][link-hellogithub]
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
</div>
@@ -67,16 +68,16 @@
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **Feature** | **Description** | **Priority** |
| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------ |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -109,9 +110,9 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
### Platform-Specific Downloads
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Web: Visit [https://web.readest.com][link-web-readest].
- macOS / iOS / iPadOS : Search and install **Readest** on the [App Store][link-appstore], _also_ available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit and download **Readest** at [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
## Requirements
@@ -303,12 +304,14 @@ The following fonts are utilized in this software, either bundled within the app
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=blue
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest?color=blue
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
[badge-donate]: https://donate.readest.com/badge.svg
[badge-deepwiki]: https://deepwiki.com/badge.svg
[badge-reddit]: https://img.shields.io/reddit/subreddit-subscribers/readest?style=flat&logo=reddit&color=F37E41
[badge-language-coverage]: https://img.shields.io/badge/coverage-52%25%20population%20🌍-green
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
@@ -322,3 +325,5 @@ The following fonts are utilized in this software, either bundled within the app
[link-koreader]: https://github.com/koreader/koreader
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
[link-deepwiki]: https://deepwiki.com/readest/readest
[link-kosync-wiki]: https://github.com/readest/readest/wiki/Sync-with-Koreader-devices
[link-reddit]: https://reddit.com/r/readest/
@@ -28,6 +28,9 @@ module.exports = {
'ar',
'th',
'bo',
'bn',
'ta',
'si',
'zh-CN',
'zh-TW',
],
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.72",
"version": "0.9.78",
"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",
@@ -495,12 +494,10 @@
"Always use latest": "استخدام الأحدث دائمًا",
"Send changes only": "إرسال التغييرات فقط",
"Receive changes only": "استلام التغييرات فقط",
"Disabled": "معطل",
"Checksum Method": "طريقة التحقق من الصحة",
"File Content (recommended)": "محتوى الملف (موصى به)",
"File Name": "اسم الملف",
"Device Name": "اسم الجهاز",
"Sync Tolerance": "تحمل المزامنة",
"Connect to your KOReader Sync server.": "الاتصال بخادم مزامنة KOReader الخاص بك.",
"Server URL": "عنوان URL للخادم",
"Username": "اسم المستخدم",
@@ -519,6 +516,27 @@
"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": "تقدم السحب",
"Previous Paragraph": "الفقرة السابقة",
"Previous Sentence": "الجملة السابقة",
"Pause": "إيقاف مؤقت",
"Play": "تشغيل",
"Next Sentence": "الجملة التالية",
"Next Paragraph": "الفقرة التالية",
"Separate Cover Page": "صفحة غلاف منفصلة"
}
@@ -0,0 +1,526 @@
{
"Email address": "ইমেইল ঠিকানা",
"Your Password": "আপনার পাসওয়ার্ড",
"Your email address": "আপনার ইমেইল ঠিকানা",
"Your password": "আপনার পাসওয়ার্ড",
"Sign in": "সাইন ইন",
"Signing in...": "সাইন ইন করা হচ্ছে...",
"Sign in with {{provider}}": "{{provider}} দিয়ে সাইন ইন",
"Already have an account? Sign in": "ইতিমধ্যে অ্যাকাউন্ট আছে? সাইন ইন করুন",
"Create a Password": "একটি পাসওয়ার্ড তৈরি করুন",
"Sign up": "সাইন আপ",
"Signing up...": "সাইন আপ করা হচ্ছে...",
"Don't have an account? Sign up": "অ্যাকাউন্ট নেই? সাইন আপ করুন",
"Check your email for the confirmation link": "নিশ্চিতকরণ লিঙ্কের জন্য ইমেইল চেক করুন",
"Signing in ...": "সাইন ইন করা হচ্ছে...",
"Send a magic link email": "ম্যাজিক লিঙ্ক ইমেইল পাঠান",
"Check your email for the magic link": "ম্যাজিক লিঙ্কের জন্য ইমেইল চেক করুন",
"Send reset password instructions": "পাসওয়ার্ড রিসেট নির্দেশনা পাঠান",
"Sending reset instructions ...": "রিসেট নির্দেশনা পাঠানো হচ্ছে...",
"Forgot your password?": "পাসওয়ার্ড ভুলে গেছেন?",
"Check your email for the password reset link": "পাসওয়ার্ড রিসেট লিঙ্কের জন্য ইমেইল চেক করুন",
"Phone number": "ফোন নম্বর",
"Your phone number": "আপনার ফোন নম্বর",
"Token": "টোকেন",
"Your OTP token": "আপনার ওটিপি টোকেন",
"Verify token": "টোকেন যাচাই করুন",
"Sign in with Google": "গুগল দিয়ে সাইন ইন",
"Sign in with Apple": "অ্যাপল দিয়ে সাইন ইন",
"Sign in with GitHub": "গিটহাব দিয়ে সাইন ইন",
"New Password": "নতুন পাসওয়ার্ড",
"Your new password": "আপনার নতুন পাসওয়ার্ড",
"Update password": "পাসওয়ার্ড আপডেট করুন",
"Updating password ...": "পাসওয়ার্ড আপডেট করা হচ্ছে...",
"Your password has been updated": "আপনার পাসওয়ার্ড আপডেট হয়েছে",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "কিছু ভুল হয়েছে। চিন্তা করবেন না, আমাদের দল জানানো হয়েছে এবং আমরা সমাধানে কাজ করছি।",
"Error Details:": "ত্রুটির বিস্তারিত:",
"Try Again": "আবার চেষ্টা করুন",
"Go Back": "ফিরে যান",
"Your Library": "আপনার লাইব্রেরি",
"Need help?": "সাহায্য দরকার?",
"Contact Support": "সাপোর্টের সাথে যোগাযোগ",
"Open": "খুলুন",
"Group": "গ্রুপ",
"Details": "বিস্তারিত",
"Delete": "মুছুন",
"Cancel": "বাতিল",
"Confirm Deletion": "মুছে ফেলা নিশ্চিত করুন",
"Are you sure to delete {{count}} selected book(s)?_one": "আপনি কি নিশ্চিত যে নির্বাচিত বইটি মুছবেন?",
"Are you sure to delete {{count}} selected book(s)?_other": "আপনি কি নিশ্চিত যে {{count}}টি নির্বাচিত বই মুছবেন?",
"Deselect Book": "বই অনির্বাচিত করুন",
"Select Book": "বই নির্বাচন করুন",
"Show Book Details": "বইয়ের বিস্তারিত দেখান",
"Download Book": "বই ডাউনলোড করুন",
"Upload Book": "বই আপলোড করুন",
"Deselect Group": "গ্রুপ অনির্বাচিত করুন",
"Select Group": "গ্রুপ নির্বাচন করুন",
"Untitled Group": "নামবিহীন গ্রুপ",
"Group Books": "বই গ্রুপ করুন",
"Remove From Group": "গ্রুপ থেকে সরান",
"Create New Group": "নতুন গ্রুপ তৈরি করুন",
"Save": "সংরক্ষণ",
"Confirm": "নিশ্চিত করুন",
"From Local File": "স্থানীয় ফাইল থেকে",
"Search in {{count}} Book(s)..._one": "বইটিতে খুঁজুন...",
"Search in {{count}} Book(s)..._other": "{{count}}টি বইয়ে খুঁজুন...",
"Search Books...": "বই খুঁজুন...",
"Clear Search": "খোঁজ পরিষ্কার করুন",
"Import Books": "বই আমদানি করুন",
"Select Multiple Books": "একাধিক বই নির্বাচন করুন",
"Select Books": "বই নির্বাচন করুন",
"Deselect": "অনির্বাচিত করুন",
"Select All": "সব নির্বাচন করুন",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} হিসেবে লগ ইন",
"Logged in": "লগ ইন করেছেন",
"Account": "অ্যাকাউন্ট",
"Sign In": "সাইন ইন",
"Auto Upload Books to Cloud": "স্বয়ংক্রিয় ক্লাউডে বই আপলোড",
"Auto Import on File Open": "ফাইল খোলার সময় স্বয়ংক্রিয় আমদানি",
"Open Last Book on Start": "শুরুতে শেষ বইটি খুলুন",
"Check Updates on Start": "শুরুতে আপডেট চেক করুন",
"Open Book in New Window": "নতুন উইন্ডোতে বই খুলুন",
"Fullscreen": "পূর্ণ স্ক্রিন",
"Always on Top": "সবসময় উপরে",
"Always Show Status Bar": "সবসময় স্ট্যাটাস বার দেখান",
"Keep Screen Awake": "স্ক্রিন জাগিয়ে রাখুন",
"Reload Page": "পৃষ্ঠা রিলোড করুন",
"Dark Mode": "ডার্ক মোড",
"Light Mode": "লাইট মোড",
"Auto Mode": "অটো মোড",
"Upgrade to Readest Premium": "রিডেস্ট প্রিমিয়ামে আপগ্রেড",
"Download Readest": "রিডেস্ট ডাউনলোড",
"About Readest": "রিডেস্ট সম্পর্কে",
"Help improve Readest": "রিডেস্ট উন্নত করতে সাহায্য করুন",
"Sharing anonymized statistics": "বেনামী পরিসংখ্যান শেয়ার করা",
"List": "তালিকা",
"Grid": "গ্রিড",
"Crop": "কাটুন",
"Fit": "ফিট",
"Title": "শিরোনাম",
"Author": "লেখক",
"Format": "ফরম্যাট",
"Date Read": "পড়ার তারিখ",
"Date Added": "যোগ করার তারিখ",
"Ascending": "ঊর্ধ্বক্রম",
"Descending": "অবতরণ ক্রম",
"Book Covers": "বইয়ের মলাট",
"Sort by...": "সাজানোর ভিত্তি...",
"No supported files found. Supported formats: {{formats}}": "কোন সমর্থিত ফাইল পাওয়া যায়নি। সমর্থিত ফরম্যাট: {{formats}}",
"No chapters detected.": "কোন অধ্যায় শনাক্ত হয়নি।",
"Failed to parse the EPUB file.": "EPUB ফাইল পার্স করতে ব্যর্থ।",
"This book format is not supported.": "এই বই ফরম্যাট সমর্থিত নয়।",
"Failed to import book(s): {{filenames}}": "বই আমদানি ব্যর্থ: {{filenames}}",
"Book uploaded: {{title}}": "বই আপলোড হয়েছে: {{title}}",
"Insufficient storage quota": "অপর্যাপ্ত স্টোরেজ কোটা",
"Failed to upload book: {{title}}": "বই আপলোড ব্যর্থ: {{title}}",
"Book downloaded: {{title}}": "বই ডাউনলোড হয়েছে: {{title}}",
"Failed to download book: {{title}}": "বই ডাউনলোড ব্যর্থ: {{title}}",
"Book deleted: {{title}}": "বই মুছে ফেলা হয়েছে: {{title}}",
"Deleted cloud backup of the book: {{title}}": "বইয়ের ক্লাউড ব্যাকআপ মুছে ফেলা হয়েছে: {{title}}",
"Deleted local copy of the book: {{title}}": "বইয়ের স্থানীয় কপি মুছে ফেলা হয়েছে: {{title}}",
"Failed to delete book: {{title}}": "বই মুছতে ব্যর্থ: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "বইয়ের ক্লাউড ব্যাকআপ মুছতে ব্যর্থ: {{title}}",
"Failed to delete local copy of the book: {{title}}": "বইয়ের স্থানীয় কপি মুছতে ব্যর্থ: {{title}}",
"Welcome to your library. You can import your books here and read them anytime.": "আপনার লাইব্রেরিতে স্বাগতম। আপনি এখানে বই আমদানি করে যেকোনো সময় পড়তে পারেন।",
"Copied to notebook": "নোটবুকে কপি করা হয়েছে",
"No annotations to export": "রপ্তানি করার জন্য কোন টীকা নেই",
"Exported from Readest": "রিডেস্ট থেকে রপ্তানি করা হয়েছে",
"Highlights & Annotations": "হাইলাইট ও টীকা",
"Untitled": "নামবিহীন",
"Note": "নোট",
"Copied to clipboard": "ক্লিপবোর্ডে কপি করা হয়েছে",
"Copy": "কপি",
"Delete Highlight": "হাইলাইট মুছুন",
"Highlight": "হাইলাইট",
"Annotate": "টীকা",
"Search": "খুঁজুন",
"Dictionary": "অভিধান",
"Wikipedia": "উইকিপিডিয়া",
"Translate": "অনুবাদ",
"Speak": "বলুন",
"Login Required": "লগইন প্রয়োজন",
"Quota Exceeded": "কোটা অতিক্রম",
"Unable to fetch the translation. Please log in first and try again.": "অনুবাদ আনতে পারছি না। প্রথমে লগইন করুন এবং আবার চেষ্টা করুন।",
"Unable to fetch the translation. Try again later.": "অনুবাদ আনতে পারছি না। পরে আবার চেষ্টা করুন।",
"Original Text": "মূল টেক্সট",
"Auto Detect": "স্বয়ংক্রিয় শনাক্তকরণ",
"(detected)": "(শনাক্ত)",
"Translated Text": "অনূদিত টেক্সট",
"System Language": "সিস্টেম ভাষা",
"Loading...": "লোড হচ্ছে...",
"No translation available.": "কোন অনুবাদ উপলব্ধ নয়।",
"Translated by {{provider}}.": "{{provider}} দ্বারা অনূদিত।",
"Bookmark": "বুকমার্ক",
"Next Section": "পরবর্তী বিভাগ",
"Previous Section": "পূর্ববর্তী বিভাগ",
"Next Page": "পরবর্তী পৃষ্ঠা",
"Previous Page": "পূর্ববর্তী পৃষ্ঠা",
"Go Forward": "এগিয়ে যান",
"Small": "ছোট",
"Large": "বড়",
"Sync Conflict": "সিঙ্ক দ্বন্দ্ব",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" থেকে পড়ার অগ্রগতি সিঙ্ক করবেন?",
"another device": "অন্য ডিভাইস",
"Local Progress": "স্থানীয় অগ্রগতি",
"Remote Progress": "দূরবর্তী অগ্রগতি",
"Failed to connect": "সংযোগ ব্যর্থ",
"Disconnected": "সংযোগ বিচ্ছিন্ন",
"KOReader Sync Settings": "KOReader সিঙ্ক সেটিংস",
"Sync as {{userDisplayName}}": "{{userDisplayName}} হিসেবে সিঙ্ক",
"Sync Server Connected": "সিঙ্ক সার্ভার সংযুক্ত",
"Sync Strategy": "সিঙ্ক কৌশল",
"Ask on conflict": "দ্বন্দ্বে জিজ্ঞাসা করুন",
"Always use latest": "সবসময় সর্বশেষ ব্যবহার করুন",
"Send changes only": "শুধু পরিবর্তন পাঠান",
"Receive changes only": "শুধু পরিবর্তন গ্রহণ করুন",
"Checksum Method": "চেকসাম পদ্ধতি",
"File Content (recommended)": "ফাইল কন্টেন্ট (সুপারিশকৃত)",
"File Name": "ফাইলের নাম",
"Device Name": "ডিভাইসের নাম",
"Connect to your KOReader Sync server.": "আপনার KOReader সিঙ্ক সার্ভারে সংযোগ করুন।",
"Server URL": "সার্ভার URL",
"Username": "ইউজারনেম",
"Your Username": "আপনার ইউজারনেম",
"Password": "পাসওয়ার্ড",
"Connect": "সংযোগ",
"Notebook": "নোটবুক",
"No notes match your search": "আপনার খোঁজের সাথে কোন নোট মিলছে না",
"Excerpts": "উদ্ধৃতি",
"Notes": "নোট",
"Add your notes here...": "এখানে আপনার নোট যোগ করুন...",
"Search notes and excerpts...": "নোট ও উদ্ধৃতি খুঁজুন...",
"{{time}} min left in chapter": "অধ্যায়ে {{time}} মিনিট বাকি",
"{{count}} pages left in chapter_one": "অধ্যায়ে ১ পৃষ্ঠা বাকি",
"{{count}} pages left in chapter_other": "অধ্যায়ে {{count}} পৃষ্ঠা বাকি",
"Theme Mode": "থিম মোড",
"Invert Image In Dark Mode": "ডার্ক মোডে ছবি উল্টান",
"Override Book Color": "বইয়ের রঙ পরিবর্তন",
"Theme Color": "থিম রঙ",
"Custom": "কাস্টম",
"Code Highlighting": "কোড হাইলাইটিং",
"Enable Highlighting": "হাইলাইটিং সক্রিয়",
"Code Language": "কোড ভাষা",
"Auto": "অটো",
"Scroll": "স্ক্রল",
"Scrolled Mode": "স্ক্রল মোড",
"Continuous Scroll": "ক্রমাগত স্ক্রল",
"Overlap Pixels": "ওভারল্যাপ পিক্সেল",
"Click": "ক্লিক",
"Clicks for Page Flip": "পৃষ্ঠা উল্টানোর জন্য ক্লিক",
"Swap Clicks Area": "ক্লিক এরিয়া অদলবদল",
"Disable Double Click": "ডাবল ক্লিক নিষ্ক্রিয়",
"Volume Keys for Page Flip": "পৃষ্ঠা উল্টানোর জন্য ভলিউম কী",
"Animation": "অ্যানিমেশন",
"Paging Animation": "পৃষ্ঠা অ্যানিমেশন",
"Security": "নিরাপত্তা",
"Allow JavaScript": "জাভাস্ক্রিপ্ট অনুমতি",
"Enable only if you trust the file.": "শুধুমাত্র ফাইলে বিশ্বাস থাকলে সক্রিয় করুন।",
"Font": "ফন্ট",
"Custom Fonts": "কাস্টম ফন্ট",
"Cancel Delete": "মুছা বাতিল",
"Delete Font": "ফন্ট মুছুন",
"Import Font": "ফন্ট আমদানি",
"Tips": "টিপস",
"Supported font formats: .ttf, .otf, .woff, .woff2": "সমর্থিত ফন্ট ফরম্যাট: .ttf, .otf, .woff, .woff2",
"Custom fonts can be selected from the Font Face menu": "কাস্টম ফন্ট Font Face মেনু থেকে নির্বাচন করা যায়",
"Global Settings": "গ্লোবাল সেটিংস",
"Apply to All Books": "সব বইয়ে প্রয়োগ",
"Apply to This Book": "এই বইয়ে প্রয়োগ",
"Reset Settings": "সেটিংস রিসেট",
"Manage Custom Fonts": "কাস্টম ফন্ট ব্যবস্থাপনা",
"System Fonts": "সিস্টেম ফন্ট",
"Serif Font": "সেরিফ ফন্ট",
"Sans-Serif Font": "স্যান্স-সেরিফ ফন্ট",
"Override Book Font": "বইয়ের ফন্ট পরিবর্তন",
"Font Size": "ফন্ট সাইজ",
"Default Font Size": "ডিফল্ট ফন্ট সাইজ",
"Minimum Font Size": "সর্বনিম্ন ফন্ট সাইজ",
"Font Weight": "ফন্ট ওয়েট",
"Font Family": "ফন্ট ফ্যামিলি",
"Default Font": "ডিফল্ট ফন্ট",
"CJK Font": "CJK ফন্ট",
"Font Face": "ফন্ট ফেস",
"Monospace Font": "মনোস্পেস ফন্ট",
"Language": "ভাষা",
"Interface Language": "ইন্টারফেস ভাষা",
"Translation": "অনুবাদ",
"Enable Translation": "অনুবাদ সক্রিয়",
"Show Source Text": "উৎস টেক্সট দেখান",
"Translation Service": "অনুবাদ সেবা",
"Translate To": "অনুবাদ করুন",
"Override Book Layout": "বইয়ের লেআউট পরিবর্তন",
"Writing Mode": "লেখার মোড",
"Default": "ডিফল্ট",
"Horizontal Direction": "অনুভূমিক দিক",
"Vertical Direction": "উল্লম্ব দিক",
"RTL Direction": "RTL দিক",
"Border Frame": "বর্ডার ফ্রেম",
"Double Border": "ডাবল বর্ডার",
"Border Color": "বর্ডার রঙ",
"Paragraph": "অনুচ্ছেদ",
"Paragraph Margin": "অনুচ্ছেদ মার্জিন",
"Line Spacing": "লাইন স্পেসিং",
"Word Spacing": "শব্দ স্পেসিং",
"Letter Spacing": "অক্ষর স্পেসিং",
"Text Indent": "টেক্সট ইনডেন্ট",
"Full Justification": "পূর্ণ সারিবদ্ধতা",
"Hyphenation": "হাইফেনেশন",
"Page": "পৃষ্ঠা",
"Top Margin (px)": "উপরের মার্জিন (px)",
"Bottom Margin (px)": "নিচের মার্জিন (px)",
"Left Margin (px)": "বাম মার্জিন (px)",
"Right Margin (px)": "ডান মার্জিন (px)",
"Column Gap (%)": "কলাম গ্যাপ (%)",
"Maximum Number of Columns": "সর্বোচ্চ কলাম সংখ্যা",
"Maximum Column Height": "সর্বোচ্চ কলাম উচ্চতা",
"Maximum Column Width": "সর্বোচ্চ কলাম প্রস্থ",
"Header & Footer": "হেডার ও ফুটার",
"Show Header": "হেডার দেখান",
"Show Footer": "ফুটার দেখান",
"Show Remaining Time": "বাকি সময় দেখান",
"Show Remaining Pages": "বাকি পৃষ্ঠা দেখান",
"Show Reading Progress": "পড়ার অগ্রগতি দেখান",
"Reading Progress Style": "পড়ার অগ্রগতির স্টাইল",
"Page Number": "পৃষ্ঠা নম্বর",
"Percentage": "শতাংশ",
"Apply also in Scrolled Mode": "স্ক্রল মোডেও প্রয়োগ করুন",
"Screen": "স্ক্রিন",
"Orientation": "দিকবিন্যাস",
"Portrait": "পোর্ট্রেট",
"Landscape": "ল্যান্ডস্কেপ",
"Apply": "প্রয়োগ",
"Custom Content CSS": "কাস্টম কন্টেন্ট CSS",
"Enter CSS for book content styling...": "বই কন্টেন্ট স্টাইলিংয়ের জন্য CSS লিখুন...",
"Custom Reader UI CSS": "কাস্টম রিডার UI CSS",
"Enter CSS for reader interface styling...": "রিডার ইন্টারফেস স্টাইলিংয়ের জন্য CSS লিখুন...",
"Layout": "লেআউট",
"Color": "রঙ",
"Behavior": "আচরণ",
"Reset {{settings}}": "{{settings}} রিসেট",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "সারা বিশ্বটাই একটা মঞ্চ,\nআর সব নর-নারী কেবল অভিনেতা;\nতাদের আছে প্রস্থান ও প্রবেশ,\nআর একজন মানুষ তার জীবনে অনেক চরিত্র পালন করে,\nতার অভিনয় সাত যুগের।\n\n— উইলিয়াম শেক্সপিয়র",
"(from 'As You Like It', Act II)": "('অ্যাজ ইউ লাইক ইট', দ্বিতীয় অঙ্ক থেকে)",
"Custom Theme": "কাস্টম থিম",
"Theme Name": "থিমের নাম",
"Text Color": "টেক্সট রঙ",
"Background Color": "ব্যাকগ্রাউন্ড রঙ",
"Link Color": "লিঙ্ক রঙ",
"Preview": "প্রিভিউ",
"Font & Layout": "ফন্ট ও লেআউট",
"Book Cover": "বইয়ের মলাট",
"More Info": "আরো তথ্য",
"Parallel Read": "সমান্তরাল পড়া",
"Disable": "নিষ্ক্রিয়",
"Enable": "সক্রিয়",
"KOReader Sync": "KOReader সিঙ্ক",
"Push Progress": "অগ্রগতি পুশ",
"Pull Progress": "অগ্রগতি পুল",
"Export Annotations": "টীকা রপ্তানি",
"Sort TOC by Page": "পৃষ্ঠা অনুযায়ী TOC সাজান",
"Edit": "সম্পাদনা",
"Search...": "খুঁজুন...",
"Book": "বই",
"Chapter": "অধ্যায়",
"Match Case": "কেস মিলান",
"Match Whole Words": "পূর্ণ শব্দ মিলান",
"Match Diacritics": "ডায়াক্রিটিক্স মিলান",
"TOC": "সূচিপত্র",
"Sidebar": "সাইডবার",
"Disable Translation": "অনুবাদ নিষ্ক্রিয়",
"Translation Not Available": "অনুবাদ উপলব্ধ নয়",
"TTS not supported for PDF": "PDF এর জন্য TTS সমর্থিত নয়",
"TTS not supported for this document": "এই ডকুমেন্টের জন্য TTS সমর্থিত নয়",
"No Timeout": "কোন টাইমআউট নেই",
"{{value}} minute": "{{value}} মিনিট",
"{{value}} minutes": "{{value}} মিনিট",
"{{value}} hour": "{{value}} ঘন্টা",
"{{value}} hours": "{{value}} ঘন্টা",
"Voices for {{lang}}": "{{lang}} এর জন্য কণ্ঠস্বর",
"Slow": "ধীর",
"Fast": "দ্রুত",
"{{engine}}: {{count}} voices_one": "{{engine}}: ১টি কণ্ঠস্বর",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}টি কণ্ঠস্বর",
"Sign in to Sync": "সিঙ্কের জন্য সাইন ইন",
"Synced at {{time}}": "{{time}} এ সিঙ্ক হয়েছে",
"Never synced": "কখনো সিঙ্ক হয়নি",
"Reading Progress Synced": "পড়ার অগ্রগতি সিঙ্ক হয়েছে",
"Page {{page}} of {{total}} ({{percentage}}%)": "{{total}} এর {{page}} পৃষ্ঠা ({{percentage}}%)",
"Current position": "বর্তমান অবস্থান",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "প্রায় {{total}} এর {{page}} পৃষ্ঠা ({{percentage}}%)",
"Approximately {{percentage}}%": "প্রায় {{percentage}}%",
"Delete Your Account?": "আপনার অ্যাকাউন্ট মুছবেন?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "এই কাজ পূর্বাবস্থায় ফেরানো যাবে না। ক্লাউডে আপনার সব ডেটা স্থায়ীভাবে মুছে যাবে।",
"Delete Permanently": "স্থায়ীভাবে মুছুন",
"Restore Purchase": "ক্রয় পুনরুদ্ধার",
"Manage Subscription": "সাবস্ক্রিপশন পরিচালনা",
"Reset Password": "পাসওয়ার্ড রিসেট",
"Sign Out": "সাইন আউট",
"Delete Account": "অ্যাকাউন্ট মুছুন",
"Upgrade to {{plan}}": "{{plan}} এ আপগ্রেড",
"Complete Your Subscription": "আপনার সাবস্ক্রিপশন সম্পূর্ণ করুন",
"Coming Soon": "শীঘ্রই আসছে",
"Upgrade to Plus or Pro": "প্লাস বা প্রো তে আপগ্রেড",
"Current Plan": "বর্তমান প্ল্যান",
"Plan Limits": "প্ল্যান সীমা",
"Failed to delete user. Please try again later.": "ইউজার মুছতে ব্যর্থ। পরে আবার চেষ্টা করুন।",
"Failed to create checkout session": "চেকআউট সেশন তৈরি করতে ব্যর্থ",
"No purchases found to restore.": "পুনরুদ্ধার করার জন্য কোন ক্রয় পাওয়া যায়নি।",
"Failed to restore purchases.": "ক্রয় পুনরুদ্ধার করতে ব্যর্থ।",
"Failed to manage subscription.": "সাবস্ক্রিপশন পরিচালনা করতে ব্যর্থ।",
"Failed to load subscription plans.": "সাবস্ক্রিপশন প্ল্যান লোড করতে ব্যর্থ।",
"Loading profile...": "প্রোফাইল লোড হচ্ছে...",
"Processing your payment...": "আপনার পেমেন্ট প্রক্রিয়া করা হচ্ছে...",
"Please wait while we confirm your subscription.": "আমরা আপনার সাবস্ক্রিপশন নিশ্চিত করার সময় অপেক্ষা করুন।",
"Payment Processing": "পেমেন্ট প্রক্রিয়াকরণ",
"Your payment is being processed. This usually takes a few moments.": "আপনার পেমেন্ট প্রক্রিয়া করা হচ্ছে। এটি সাধারণত কয়েক মুহূর্ত সময় নেয়।",
"Payment Failed": "পেমেন্ট ব্যর্থ",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "আমরা আপনার সাবস্ক্রিপশন প্রক্রিয়া করতে পারিনি। আবার চেষ্টা করুন বা সমস্যা অব্যাহত থাকলে সাপোর্টের সাথে যোগাযোগ করুন।",
"Back to Profile": "প্রোফাইলে ফিরুন",
"Subscription Successful!": "সাবস্ক্রিপশন সফল!",
"Thank you for your subscription! Your payment has been processed successfully.": "আপনার সাবস্ক্রিপশনের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়া করা হয়েছে।",
"Email:": "ইমেইল:",
"Plan:": "প্ল্যান:",
"Amount:": "পরিমাণ:",
"Subscription ID:": "সাবস্ক্রিপশন ID:",
"Go to Library": "লাইব্রেরিতে যান",
"Need help? Contact our support team at support@readest.com": "সাহায্য দরকার? support@readest.com এ আমাদের সাপোর্ট টিমের সাথে যোগাযোগ করুন",
"Free Plan": "ফ্রি প্ল্যান",
"month": "মাস",
"year": "বছর",
"Cross-Platform Sync": "ক্রস-প্ল্যাটফর্ম সিঙ্ক",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "আপনার সব ডিভাইসে লাইব্রেরি, অগ্রগতি, হাইলাইট এবং নোট নির্বিঘ্নে সিঙ্ক করুন—আর কখনো আপনার জায়গা হারাবেন না।",
"Customizable Reading": "কাস্টমাইজযোগ্য পড়া",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "নিখুঁত পড়ার অভিজ্ঞতার জন্য সামঞ্জস্যযোগ্য ফন্ট, লেআউট, থিম এবং উন্নত ডিসপ্লে সেটিংস দিয়ে প্রতিটি বিস্তারিত ব্যক্তিগতকরণ করুন।",
"AI Read Aloud": "AI পড়ে শোনানো",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "প্রাকৃতিক শোনা AI কণ্ঠস্বর দিয়ে হ্যান্ডস-ফ্রি পড়া উপভোগ করুন যা আপনার বইগুলোকে জীবন্ত করে তোলে।",
"AI Translations": "AI অনুবাদ",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Google, Azure, বা DeepL এর শক্তি দিয়ে যেকোনো টেক্সট তাৎক্ষণিক অনুবাদ করুন—যেকোনো ভাষায় কন্টেন্ট বুঝুন।",
"Community Support": "কমিউনিটি সাপোর্ট",
"Connect with fellow readers and get help fast in our friendly community channels.": "সহ পাঠকদের সাথে যুক্ত হোন এবং আমাদের বন্ধুত্বপূর্ণ কমিউনিটি চ্যানেলে দ্রুত সাহায্য পান।",
"Cloud Sync Storage": "ক্লাউড সিঙ্ক স্টোরেজ",
"AI Translations (per day)": "AI অনুবাদ (প্রতিদিন)",
"Plus Plan": "প্লাস প্ল্যান",
"Includes All Free Plan Benefits": "সব ফ্রি প্ল্যান সুবিধা অন্তর্ভুক্ত",
"Unlimited AI Read Aloud Hours": "সীমাহীন AI পড়ে শোনানোর সময়",
"Listen without limits—convert as much text as you like into immersive audio.": "সীমা ছাড়াই শুনুন—যত খুশি টেক্সটকে নিমজ্জনকারী অডিওতে রূপান্তর করুন।",
"More AI Translations": "আরো AI অনুবাদ",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "আরো দৈনিক ব্যবহার এবং উন্নত অপশন দিয়ে উন্নত অনুবাদ ক্ষমতা আনলক করুন।",
"DeepL Pro Access": "DeepL Pro অ্যাক্সেস",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "উপলব্ধ সবচেয়ে নির্ভুল অনুবাদ ইঞ্জিন দিয়ে দৈনিক ১,০০,০০০ অক্ষর পর্যন্ত অনুবাদ করুন।",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "৫ GB ক্লাউড স্টোরেজ দিয়ে আপনার সম্পূর্ণ পড়ার সংগ্রহ নিরাপদে সংরক্ষণ এবং অ্যাক্সেস করুন।",
"Priority Support": "অগ্রাধিকার সাপোর্ট",
"Enjoy faster responses and dedicated assistance whenever you need help.": "যখনই সাহায্যের প্রয়োজন হয় দ্রুত প্রতিক্রিয়া এবং নিবেদিত সহায়তা উপভোগ করুন।",
"Pro Plan": "প্রো প্ল্যান",
"Includes All Plus Plan Benefits": "সব প্লাস প্ল্যান সুবিধা অন্তর্ভুক্ত",
"Early Feature Access": "আর্লি ফিচার অ্যাক্সেস",
"Be the first to explore new features, updates, and innovations before anyone else.": "অন্য কারো আগে নতুন ফিচার, আপডেট এবং উদ্ভাবন অন্বেষণ করার প্রথম হয়ে উঠুন।",
"Advanced AI Tools": "উন্নত AI টুলস",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "স্মার্ট পড়া, অনুবাদ এবং কন্টেন্ট আবিষ্কারের জন্য শক্তিশালী AI টুলস ব্যবহার করুন।",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "উপলব্ধ সবচেয়ে নির্ভুল অনুবাদ ইঞ্জিন দিয়ে দৈনিক ৫,০০,০০০ অক্ষর পর্যন্ত অনুবাদ করুন।",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "২০ GB ক্লাউড স্টোরেজ দিয়ে আপনার সম্পূর্ণ পড়ার সংগ্রহ নিরাপদে সংরক্ষণ এবং অ্যাক্সেস করুন।",
"Version {{version}}": "সংস্করণ {{version}}",
"Check Update": "আপডেট চেক করুন",
"Already the latest version": "ইতিমধ্যে সর্বশেষ সংস্করণ",
"Checking for updates...": "আপডেট চেক করা হচ্ছে...",
"Error checking for updates": "আপডেট চেক করতে ত্রুটি",
"Drop to Import Books": "বই আমদানি করতে ড্রপ করুন",
"Terms of Service": "সেবার শর্তাবলী",
"Privacy Policy": "গোপনীয়তা নীতি",
"Enter book title": "বইয়ের শিরোনাম লিখুন",
"Subtitle": "উপশিরোনাম",
"Enter book subtitle": "বইয়ের উপশিরোনাম লিখুন",
"Enter author name": "লেখকের নাম লিখুন",
"Series": "সিরিজ",
"Enter series name": "সিরিজের নাম লিখুন",
"Series Index": "সিরিজ ইনডেক্স",
"Enter series index": "সিরিজ ইনডেক্স লিখুন",
"Total in Series": "সিরিজের মোট",
"Enter total books in series": "সিরিজের মোট বই সংখ্যা লিখুন",
"Publisher": "প্রকাশক",
"Enter publisher": "প্রকাশক লিখুন",
"Publication Date": "প্রকাশনার তারিখ",
"YYYY or YYYY-MM-DD": "YYYY বা YYYY-MM-DD",
"Identifier": "শনাক্তকারী",
"Subjects": "বিষয়",
"Fiction, Science, History": "কল্পকাহিনী, বিজ্ঞান, ইতিহাস",
"Description": "বিবরণ",
"Enter book description": "বইয়ের বিবরণ লিখুন",
"Change cover image": "মলাট ছবি পরিবর্তন",
"Replace": "প্রতিস্থাপন",
"Unlock cover": "মলাট আনলক",
"Lock cover": "মলাট লক",
"Auto-Retrieve Metadata": "মেটাডেটা স্বয়ংক্রিয় পুনরুদ্ধার",
"Auto-Retrieve": "স্বয়ংক্রিয় পুনরুদ্ধার",
"Unlock all fields": "সব ফিল্ড আনলক",
"Unlock All": "সব আনলক",
"Lock all fields": "সব ফিল্ড লক",
"Lock All": "সব লক",
"Reset": "রিসেট",
"Are you sure to delete the selected book?": "আপনি কি নিশ্চিত যে নির্বাচিত বইটি মুছবেন?",
"Are you sure to delete the cloud backup of the selected book?": "আপনি কি নিশ্চিত যে নির্বাচিত বইয়ের ক্লাউড ব্যাকআপ মুছবেন?",
"Are you sure to delete the local copy of the selected book?": "আপনি কি নিশ্চিত যে নির্বাচিত বইয়ের স্থানীয় কপি মুছবেন?",
"Edit Metadata": "মেটাডেটা সম্পাদনা",
"Book Details": "বইয়ের বিস্তারিত",
"Unknown": "অজানা",
"Remove from Cloud & Device": "ক্লাউড ও ডিভাইস থেকে সরান",
"Remove from Cloud Only": "শুধু ক্লাউড থেকে সরান",
"Remove from Device Only": "শুধু ডিভাইস থেকে সরান",
"Published": "প্রকাশিত",
"Updated": "আপডেট হয়েছে",
"Added": "যোগ করা হয়েছে",
"File Size": "ফাইল সাইজ",
"No description available": "কোন বিবরণ উপলব্ধ নয়",
"Locked": "লক করা",
"Select Metadata Source": "মেটাডেটা সোর্স নির্বাচন",
"Keep manual input": "ম্যানুয়াল ইনপুট রাখুন",
"A new version of Readest is available!": "রিডেস্টের একটি নতুন সংস্করণ উপলব্ধ!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "রিডেস্ট {{newVersion}} উপলব্ধ (ইনস্টল করা সংস্করণ {{currentVersion}})।",
"Download and install now?": "এখনই ডাউনলোড এবং ইনস্টল করবেন?",
"Downloading {{downloaded}} of {{contentLength}}": "{{contentLength}} এর {{downloaded}} ডাউনলোড হচ্ছে",
"Download finished": "ডাউনলোড সম্পূর্ণ",
"DOWNLOAD & INSTALL": "ডাউনলোড ও ইনস্টল",
"Changelog": "পরিবর্তন লগ",
"Software Update": "সফটওয়্যার আপডেট",
"What's New in Readest": "রিডেস্টে নতুন কী",
"Select Files": "ফাইল নির্বাচন",
"Select Image": "ছবি নির্বাচন",
"Select Video": "ভিডিও নির্বাচন",
"Select Audio": "অডিও নির্বাচন",
"Select Fonts": "ফন্ট নির্বাচন",
"Storage": "স্টোরেজ",
"{{percentage}}% of Cloud Sync Space Used.": "ক্লাউড সিঙ্ক স্পেসের {{percentage}}% ব্যবহৃত।",
"Translation Characters": "অনুবাদ অক্ষর",
"{{percentage}}% of Daily Translation Characters Used.": "দৈনিক অনুবাদ অক্ষরের {{percentage}}% ব্যবহৃত।",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "দৈনিক অনুবাদ কোটা শেষ। AI অনুবাদ ব্যবহার চালিয়ে যেতে আপনার প্ল্যান আপগ্রেড করুন।",
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "গুগল বুকস",
"Open Library": "ওপেন লাইব্রেরি",
"Azure Translator": "Azure অনুবাদক",
"DeepL": "DeepL",
"Google Translate": "গুগল ট্রান্সলেট",
"Yandex Translate": "ইয়ান্ডেক্স ট্রান্সলেট",
"Gray": "ধূসর",
"Sepia": "সেপিয়া",
"Grass": "ঘাস",
"Cherry": "চেরি",
"Sky": "আকাশ",
"Solarized": "সোলারাইজড",
"Gruvbox": "গ্রুভবক্স",
"Nord": "নর্ড",
"Contrast": "কন্ট্রাস্ট",
"Sunset": "সূর্যাস্ত",
"Reveal in Finder": "ফাইন্ডারে দেখান",
"Reveal in File Explorer": "ফাইল এক্সপ্লোরারে দেখান",
"Reveal in Folder": "ফোল্ডারে দেখান",
"Previous Paragraph": "পূর্ববর্তী অনুচ্ছেদ",
"Previous Sentence": "পূর্ববর্তী বাক্য",
"Pause": "বিরতি",
"Play": "চালান",
"Next Sentence": "পরবর্তী বাক্য",
"Next Paragraph": "পরবর্তী অনুচ্ছেদ",
"Separate Cover Page": "আলাদা কভার পৃষ্ঠা"
}
@@ -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 སྒྱུར་བཅོས།",
@@ -477,13 +476,10 @@
"Always use latest": "དེ་ལས་འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Send changes only": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Receive changes only": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
"Disabled": "བཀག་པ།",
"Checksum Method": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
"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 +494,29 @@
"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": "འདྲུད་པའི་འཕེལ་རིམ།",
"Previous Paragraph": "སྔོན་གྱི་དོན་ཚན།",
"Previous Sentence": "སྔོན་གྱི་ཚིག་གྲུབ།",
"Pause": "སྐར་མ་འཇོག",
"Play": "གཏོང་བ།",
"Next Sentence": "རྗེས་ཀྱི་ཚིག་གྲུབ།",
"Next Paragraph": "རྗེས་ཀྱི་དོན་ཚན།",
"Separate Cover Page": "སྟོང་ངོས་གི་ཤོག་དཀར་པ།"
}
@@ -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",
@@ -479,12 +478,10 @@
"Always use latest": "Immer die neueste Version verwenden",
"Send changes only": "Nur Änderungen senden",
"Receive changes only": "Nur Änderungen empfangen",
"Disabled": "Deaktiviert",
"Checksum Method": "Prüfziffernverfahren",
"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 +500,27 @@
"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",
"Previous Paragraph": "Vorheriger Absatz",
"Previous Sentence": "Vorheriger Satz",
"Pause": "Pause",
"Play": "Wiedergabe",
"Next Sentence": "Nächster Satz",
"Next Paragraph": "Nächster Absatz",
"Separate Cover Page": "Titelseite separat anzeigen"
}
@@ -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",
@@ -479,12 +478,10 @@
"Always use latest": "Χρησιμοποίησε πάντα την τελευταία έκδοση",
"Send changes only": "Αποστολή μόνο αλλαγών",
"Receive changes only": "Λήψη μόνο αλλαγών",
"Disabled": "Απενεργοποιημένο",
"Checksum Method": "Μέθοδος ελέγχου",
"File Content (recommended)": "Περιεχόμενο αρχείου (συνιστάται)",
"File Name": "Όνομα αρχείου",
"Device Name": "Όνομα συσκευής",
"Sync Tolerance": "Ανοχή συγχρονισμού",
"Connect to your KOReader Sync server.": "Συνδεθείτε στον διακομιστή συγχρονισμού KOReader.",
"Server URL": "Διεύθυνση URL διακομιστή",
"Username": "Όνομα χρήστη",
@@ -503,6 +500,27 @@
"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": "Λήψη προόδου",
"Previous Paragraph": "Προηγούμενη παράγραφος",
"Previous Sentence": "Προηγούμενη πρόταση",
"Pause": "Παύση",
"Play": "Αναπαραγωγή",
"Next Sentence": "Επόμενη πρόταση",
"Next Paragraph": "Επόμενη παράγραφος",
"Separate Cover Page": "Ξεχωριστή σελίδα εξωφύλλου"
}
@@ -83,12 +83,10 @@
"Always use latest": "Usar siempre el más reciente",
"Send changes only": "Solo enviar cambios",
"Receive changes only": "Solo recibir cambios",
"Disabled": "Desactivado",
"Checksum Method": "Método de identificación",
"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 +474,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 +504,27 @@
"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",
"Previous Paragraph": "Párrafo anterior",
"Previous Sentence": "Oración anterior",
"Pause": "Pausar",
"Play": "Reproducir",
"Next Sentence": "Siguiente oración",
"Next Paragraph": "Siguiente párrafo",
"Separate Cover Page": "Portada separada"
}
@@ -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",
@@ -483,12 +482,10 @@
"Always use latest": "Toujours utiliser la dernière version",
"Send changes only": "Envoyer uniquement les modifications",
"Receive changes only": "Recevoir uniquement les modifications",
"Disabled": "Désactivé",
"Checksum Method": "Méthode de somme de contrôle",
"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 +504,27 @@
"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",
"Previous Paragraph": "Paragraphe précédent",
"Previous Sentence": "Phrase précédente",
"Pause": "Pause",
"Play": "Lecture",
"Next Sentence": "Phrase suivante",
"Next Paragraph": "Paragraphe suivant",
"Separate Cover Page": "Page de couverture séparée"
}
@@ -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": "यांडेक्स अनुवाद",
@@ -479,12 +478,10 @@
"Always use latest": "हमेशा नवीनतम का उपयोग करें",
"Send changes only": "केवल परिवर्तन भेजें",
"Receive changes only": "केवल परिवर्तन प्राप्त करें",
"Disabled": "अक्षम",
"Checksum Method": "चेकसम विधि",
"File Content (recommended)": "फाइल सामग्री (अनुशंसित)",
"File Name": "फाइल नाम",
"Device Name": "डिवाइस नाम",
"Sync Tolerance": "सिंक सहिष्णुता",
"Connect to your KOReader Sync server.": "अपने KOReader सिंक सर्वर से कनेक्ट करें।",
"Server URL": "सर्वर यूआरएल",
"Username": "उपयोगकर्ता नाम",
@@ -503,6 +500,27 @@
"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": "पुल प्रगति",
"Previous Paragraph": "पिछला पैराग्राफ",
"Previous Sentence": "पिछला वाक्य",
"Pause": "रोकें",
"Play": "चलाएं",
"Next Sentence": "अगला वाक्य",
"Next Paragraph": "अगला पैराग्राफ",
"Separate Cover Page": "अलग कवर पृष्ठ"
}
@@ -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",
@@ -475,12 +474,10 @@
"Always use latest": "Selalu gunakan yang terbaru",
"Send changes only": "Kirim perubahan saja",
"Receive changes only": "Terima perubahan saja",
"Disabled": "Dinonaktifkan",
"Checksum Method": "Metode Checksum",
"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 +496,27 @@
"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",
"Previous Paragraph": "Paragraf Sebelumnya",
"Previous Sentence": "Kalimat Sebelumnya",
"Pause": "Jeda",
"Play": "Putar",
"Next Sentence": "Kalimat Berikutnya",
"Next Paragraph": "Paragraf Berikutnya",
"Separate Cover Page": "Halaman Sampul Terpisah"
}
@@ -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",
@@ -483,12 +482,10 @@
"Always use latest": "Usa sempre l'ultima versione",
"Send changes only": "Invia solo le modifiche",
"Receive changes only": "Ricevi solo le modifiche",
"Disabled": "Disabilitato",
"Checksum Method": "Metodo di Controllo",
"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 +504,27 @@
"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",
"Previous Paragraph": "Paragrafo precedente",
"Previous Sentence": "Frase precedente",
"Pause": "Pausa",
"Play": "Riproduci",
"Next Sentence": "Frase successiva",
"Next Paragraph": "Paragrafo successivo",
"Separate Cover Page": "Pagina di copertina separata"
}
@@ -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翻訳",
@@ -475,12 +474,10 @@
"Always use latest": "最新を常に使用",
"Send changes only": "変更のみを送信",
"Receive changes only": "変更のみを受信",
"Disabled": "無効",
"Checksum Method": "チェックサム方式",
"File Content (recommended)": "ファイル内容(推奨)",
"File Name": "ファイル名",
"Device Name": "デバイス名",
"Sync Tolerance": "同期許容範囲",
"Connect to your KOReader Sync server.": "KOReader Syncサーバーに接続します。",
"Server URL": "サーバーURL",
"Username": "ユーザー名",
@@ -499,6 +496,27 @@
"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": "進捗をプル",
"Previous Paragraph": "前の段落",
"Previous Sentence": "前の文",
"Pause": "一時停止",
"Play": "再生",
"Next Sentence": "次の文",
"Next Paragraph": "次の段落",
"Separate Cover Page": "別の表紙ページ"
}
@@ -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 번역",
@@ -475,12 +474,10 @@
"Always use latest": "항상 최신 버전 사용",
"Send changes only": "변경 사항만 전송",
"Receive changes only": "변경 사항만 수신",
"Disabled": "비활성화",
"Checksum Method": "체크섬 방법",
"File Content (recommended)": "파일 내용 (권장)",
"File Name": "파일 이름",
"Device Name": "장치 이름",
"Sync Tolerance": "동기화 허용 오차",
"Connect to your KOReader Sync server.": "KOReader Sync 서버에 연결합니다.",
"Server URL": "서버 URL",
"Username": "사용자 이름",
@@ -499,6 +496,27 @@
"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": "진행 상황 끌어오기",
"Previous Paragraph": "이전 단락",
"Previous Sentence": "이전 문장",
"Pause": "일시정지",
"Play": "재생",
"Next Sentence": "다음 문장",
"Next Paragraph": "다음 단락",
"Separate Cover Page": "별도의 표지 페이지"
}
@@ -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",
@@ -479,12 +478,10 @@
"Always use latest": "Altijd de nieuwste gebruiken",
"Send changes only": "Verzend alleen wijzigingen",
"Receive changes only": "Ontvang alleen wijzigingen",
"Disabled": "Uitgeschakeld",
"Checksum Method": "Checksum-methode",
"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 +500,27 @@
"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",
"Previous Paragraph": "Vorige alinea",
"Previous Sentence": "Vorige zin",
"Pause": "Pauzeren",
"Play": "Afspelen",
"Next Sentence": "Volgende zin",
"Next Paragraph": "Volgende alinea",
"Separate Cover Page": "Afzonderlijke omslagpagina"
}
@@ -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",
@@ -487,12 +486,10 @@
"Always use latest": "Zawsze używaj najnowszej",
"Send changes only": "Wyślij tylko zmiany",
"Receive changes only": "Odbierz tylko zmiany",
"Disabled": "Wyłączone",
"Checksum Method": "Metoda sumy kontrolnej",
"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 +508,27 @@
"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",
"Previous Paragraph": "Poprzedni akapit",
"Previous Sentence": "Poprzednie zdanie",
"Pause": "Pauza",
"Play": "Odtwórz",
"Next Sentence": "Następne zdanie",
"Next Paragraph": "Następny akapit",
"Separate Cover Page": "Osobna strona okładki"
}
@@ -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",
@@ -483,12 +482,10 @@
"Always use latest": "Sempre usar o mais recente",
"Send changes only": "Enviar apenas alterações",
"Receive changes only": "Receber apenas alterações",
"Disabled": "Desativado",
"Checksum Method": "Método de Verificação",
"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 +504,27 @@
"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",
"Previous Paragraph": "Parágrafo anterior",
"Previous Sentence": "Frase anterior",
"Pause": "Pausar",
"Play": "Reproduzir",
"Next Sentence": "Próxima frase",
"Next Paragraph": "Próximo parágrafo",
"Separate Cover Page": "Páginas de Capa Separadas"
}
@@ -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",
@@ -487,12 +486,10 @@
"Always use latest": "Всегда использовать последнюю",
"Send changes only": "Отправлять только изменения",
"Receive changes only": "Получать только изменения",
"Disabled": "Отключено",
"Checksum Method": "Метод контрольной суммы",
"File Content (recommended)": "Содержимое файла (рекомендуется)",
"File Name": "Имя файла",
"Device Name": "Имя устройства",
"Sync Tolerance": "Допуск синхронизации",
"Connect to your KOReader Sync server.": "Подключитесь к вашему серверу синхронизации KOReader.",
"Server URL": "URL сервера",
"Username": "Имя пользователя",
@@ -511,6 +508,27 @@
"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": "Получить прогресс",
"Previous Paragraph": "Предыдущий абзац",
"Previous Sentence": "Предыдущее предложение",
"Pause": "Пауза",
"Play": "Воспроизвести",
"Next Sentence": "Следующее предложение",
"Next Paragraph": "Следующий абзац",
"Separate Cover Page": "Отдельная страница обложки"
}
@@ -0,0 +1,526 @@
{
"Email address": "ඊමේල් ලිපිනය",
"Your Password": "ඔබේ මුරපදය",
"Your email address": "ඔබේ ඊමේල් ලිපිනය",
"Your password": "ඔබේ මුරපදය",
"Sign in": "ඇතුල් වන්න",
"Signing in...": "ඇතුල් වෙමින්...",
"Sign in with {{provider}}": "{{provider}} සමඟ ඇතුල් වන්න",
"Already have an account? Sign in": "දැනටමත් ගිණුමක් තිබේද? ඇතුල් වන්න",
"Create a Password": "මුරපදයක් සාදන්න",
"Sign up": "ලියාපදිංචි වන්න",
"Signing up...": "ලියාපදිංචි වෙමින්...",
"Don't have an account? Sign up": "ගිණුමක් නැද්ද? ලියාපදිංචි වන්න",
"Check your email for the confirmation link": "තහවුරු කිරීමේ සබැඳිය සඳහා ඔබේ ඊමේල් පරීක්ෂා කරන්න",
"Signing in ...": "ඇතුල් වෙමින්...",
"Send a magic link email": "මැජික් සබැඳි ඊමේල් යවන්න",
"Check your email for the magic link": "මැජික් සබැඳිය සඳහා ඔබේ ඊමේල් පරීක්ෂා කරන්න",
"Send reset password instructions": "මුරපදය නැවත සැකසීමේ උපදෙස් යවන්න",
"Sending reset instructions ...": "නැවත සැකසීමේ උපදෙස් යවමින්...",
"Forgot your password?": "මුරපදය අමතකද?",
"Check your email for the password reset link": "මුරපදය නැවත සැකසීමේ සබැඳිය සඳහා ඊමේල් පරීක්ෂා කරන්න",
"Phone number": "දුරකථන අංකය",
"Your phone number": "ඔබේ දුරකථන අංකය",
"Token": "ටෝකනය",
"Your OTP token": "ඔබේ OTP ටෝකනය",
"Verify token": "ටෝකනය තහවුරු කරන්න",
"Sign in with Google": "Google සමඟ ඇතුල් වන්න",
"Sign in with Apple": "Apple සමඟ ඇතුල් වන්න",
"Sign in with GitHub": "GitHub සමඟ ඇතුල් වන්න",
"New Password": "නව මුරපදය",
"Your new password": "ඔබේ නව මුරපදය",
"Update password": "මුරපදය යාවත්කාලීන කරන්න",
"Updating password ...": "මුරපදය යාවත්කාලීන කරමින්...",
"Your password has been updated": "ඔබේ මුරපදය යාවත්කාලීන කරන ලදී",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "යමක් වැරදුණි. කනගාටු නොවන්න, අපේ කණ්ඩායමට දැනුම් දී ඇති අතර අපි විසඳුමක් සඳහා කටයුතු කරමින් සිටිමු.",
"Error Details:": "දෝෂ විස්තර:",
"Try Again": "නැවත උත්සාහ කරන්න",
"Go Back": "ආපසු යන්න",
"Your Library": "ඔබේ පුස්තකාලය",
"Need help?": "උදව් අවශ්‍යද?",
"Contact Support": "සහාය සඳහා සම්බන්ධ වන්න",
"Open": "විවෘත කරන්න",
"Group": "කණ්ඩායම",
"Details": "විස්තර",
"Delete": "මකන්න",
"Cancel": "අවලංගු කරන්න",
"Confirm Deletion": "මකා දැමීම තහවුරු කරන්න",
"Are you sure to delete {{count}} selected book(s)?_one": "ඔබට විශ්වාසද තෝරාගත් පොත මකා දැමීමට?",
"Are you sure to delete {{count}} selected book(s)?_other": "ඔබට විශ්වාසද තෝරාගත් පොත් {{count}} මකා දැමීමට?",
"Deselect Book": "පොත අනිශ්චිත කරන්න",
"Select Book": "පොත තෝරන්න",
"Show Book Details": "පොතේ විස්තර පෙන්වන්න",
"Download Book": "පොත බාගන්න",
"Upload Book": "පොත උඩුගත කරන්න",
"Deselect Group": "කණ්ඩායම අනිශ්චිත කරන්න",
"Select Group": "කණ්ඩායම තෝරන්න",
"Untitled Group": "නම් නැති කණ්ඩායම",
"Group Books": "පොත් කණ්ඩායම් කරන්න",
"Remove From Group": "කණ්ඩායමෙන් ඉවත් කරන්න",
"Create New Group": "නව කණ්ඩායමක් සාදන්න",
"Save": "සුරකින්න",
"Confirm": "තහවුරු කරන්න",
"From Local File": "දේශීය ගොනුවෙන්",
"Search in {{count}} Book(s)..._one": "පොතක සොයන්න...",
"Search in {{count}} Book(s)..._other": "පොත් {{count}} කින් සොයන්න...",
"Search Books...": "පොත් සොයන්න...",
"Clear Search": "සෙවීම මකන්න",
"Import Books": "පොත් ආයාත කරන්න",
"Select Multiple Books": "පොත් කිහිපයක් තෝරන්න",
"Select Books": "පොත් තෝරන්න",
"Deselect": "අනිශ්චිත කරන්න",
"Select All": "සියල්ල තෝරන්න",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} ලෙස පුරන්ගද්",
"Logged in": "පුරනගද්",
"Account": "ගිණුම",
"Sign In": "ඇතුල් වන්න",
"Auto Upload Books to Cloud": "ස්වයංක්‍රීයව cloud එකට පොත් උඩුගත කරන්න",
"Auto Import on File Open": "ගොනුව විවෘත කිරීමේදී ස්වයංක්‍රීයව ආයාත කරන්න",
"Open Last Book on Start": "ආරම්භයේදී අන්තිම පොත විවෘත කරන්න",
"Check Updates on Start": "ආරම්භයේදී යාවත්කාලීන පරීක්ෂා කරන්න",
"Open Book in New Window": "නව කවුළුවක පොත විවෘත කරන්න",
"Fullscreen": "සම්පූර්ණ තිරය",
"Always on Top": "සෑම විටම ඉහළින්",
"Always Show Status Bar": "සෑම විටම තත්ත්ව තීරුව පෙන්වන්න",
"Keep Screen Awake": "තිරය අවදි තබන්න",
"Reload Page": "පිටුව නැවත පූරණය කරන්න",
"Dark Mode": "අඳුරු ආකාරය",
"Light Mode": "ආලෝකමය ආකාරය",
"Auto Mode": "ස්වයංක්‍රීය ආකාරය",
"Upgrade to Readest Premium": "Readest Premium වෙත උසස් කරන්න",
"Download Readest": "Readest බාගන්න",
"About Readest": "Readest ගැන",
"Help improve Readest": "Readest වැඩිදියුණු කිරීමට උදව් කරන්න",
"Sharing anonymized statistics": "අනන්‍ය සංඛ්‍යාලේඛන බෙදාගැනීම",
"List": "ලැයිස්තුව",
"Grid": "ජාලකය",
"Crop": "කපන්න",
"Fit": "ගැලපෙන",
"Title": "නම",
"Author": "කතෘ",
"Format": "ආකෘතිය",
"Date Read": "කියැවූ දිනය",
"Date Added": "එක් කළ දිනය",
"Ascending": "ආරෝහණ",
"Descending": "අවරෝහණ",
"Book Covers": "පොත් ආවරණ",
"Sort by...": "අනුව සකස් කරන්න...",
"No supported files found. Supported formats: {{formats}}": "සහාය දක්වන ගොනු හමුනොවිණි. සහාය දක්වන ආකෘති: {{formats}}",
"No chapters detected.": "කොටස් හඳුනානොගැනිණි.",
"Failed to parse the EPUB file.": "EPUB ගොනුව විග්‍රහ කිරීමට අසමත්.",
"This book format is not supported.": "මෙම පොත ආකෘතියට සහාය නොදක්වයි.",
"Failed to import book(s): {{filenames}}": "පොත් ආයාත කිරීමට අසමත්: {{filenames}}",
"Book uploaded: {{title}}": "පොත උඩුගත විය: {{title}}",
"Insufficient storage quota": "ප්‍රමාණවත් නොවන ගබඩා ප්‍රමාණය",
"Failed to upload book: {{title}}": "පොත උඩුගත කිරීමට අසමත්: {{title}}",
"Book downloaded: {{title}}": "පොත බාගන්නා ලදී: {{title}}",
"Failed to download book: {{title}}": "පොත බාගැනීමට අසමත්: {{title}}",
"Book deleted: {{title}}": "පොත මකා දමන ලදී: {{title}}",
"Deleted cloud backup of the book: {{title}}": "පොතේ cloud backup මකා දමන ලදී: {{title}}",
"Deleted local copy of the book: {{title}}": "පොතේ දේශීය පිටපත මකා දමන ලදී: {{title}}",
"Failed to delete book: {{title}}": "පොත මකා දැමීමට අසමත්: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "පොතේ cloud backup මකා දැමීමට අසමත්: {{title}}",
"Failed to delete local copy of the book: {{title}}": "පොතේ දේශීය පිටපත මකා දැමීමට අසමත්: {{title}}",
"Welcome to your library. You can import your books here and read them anytime.": "ඔබේ පුස්තකාලයට සාදරයෙන් පිළිගනිමු. ඔබට මෙහි ඔබේ පොත් ආයාත කර ඕනෑම වේලාවක කියවිය හැක.",
"Copied to notebook": "සටහන් පොතට පිටපත් කරන ලදී",
"No annotations to export": "නිර්යාත කිරීමට සටහන් නැත",
"Exported from Readest": "Readest වෙතින් නිර්යාත කරන ලදී",
"Highlights & Annotations": "ඉස්මතු කිරීම් සහ සටහන්",
"Untitled": "නම් නැති",
"Note": "සටහන",
"Copied to clipboard": "clipboard එකට පිටපත් කරන ලදී",
"Copy": "පිටපත් කරන්න",
"Delete Highlight": "ඉස්මතු කිරීම මකන්න",
"Highlight": "ඉස්මතු කරන්න",
"Annotate": "සටහන් තබන්න",
"Search": "සොයන්න",
"Dictionary": "ශබ්දකෝෂය",
"Wikipedia": "විකිපීඩියා",
"Translate": "පරිවර්තනය කරන්න",
"Speak": "කථා කරන්න",
"Login Required": "පිවිසීම අවශ්‍ය",
"Quota Exceeded": "ප්‍රමාණය ඉක්මවන ලදී",
"Unable to fetch the translation. Please log in first and try again.": "පරිවර්තනය ලබා ගැනීමට නොහැකි. කරුණාකර පළමුව ඇතුල් වී නැවත උත්සාහ කරන්න.",
"Unable to fetch the translation. Try again later.": "පරිවර්තනය ලබා ගැනීමට නොහැකි. පසුව නැවත උත්සාහ කරන්න.",
"Original Text": "මුල් පෙළ",
"Auto Detect": "ස්වයංක්‍රීයව අනාවරණය",
"(detected)": "(අනාවරණය විය)",
"Translated Text": "පරිවර්තනය කළ පෙළ",
"System Language": "පද්ධති භාෂාව",
"Loading...": "පූරණය වෙමින්...",
"No translation available.": "පරිවර්තනය ලබා ගත නොහැක.",
"Translated by {{provider}}.": "{{provider}} විසින් පරිවර්තනය කරන ලදී.",
"Bookmark": "පොත් සලකුණ",
"Next Section": "ඊළඟ කොටස",
"Previous Section": "පෙර කොටස",
"Next Page": "ඊළඟ පිටුව",
"Previous Page": "පෙර පිටුව",
"Go Forward": "ඉදිරියට යන්න",
"Small": "කුඩා",
"Large": "විශාල",
"Sync Conflict": "සමමුහුර්ත ගැටුම",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" වෙතින් කියවීමේ ප්‍රගතිය සමමුහුර්ත කරන්නද?",
"another device": "වෙනත් උපකරණයක්",
"Local Progress": "දේශීය ප්‍රගතිය",
"Remote Progress": "දුරස්ථ ප්‍රගතිය",
"Failed to connect": "සම්බන්ධ වීමට අසමත්",
"Disconnected": "විසන්ධි වුණි",
"KOReader Sync Settings": "KOReader සමමුහුර්ත සැකසුම්",
"Sync as {{userDisplayName}}": "{{userDisplayName}} ලෙස සමමුහුර්ත කරන්න",
"Sync Server Connected": "සමමුහුර්ත සේවාදායකය සම්බන්ධ විය",
"Sync Strategy": "සමමුහුර්ත උපාය",
"Ask on conflict": "ගැටුමේදී විමසන්න",
"Always use latest": "සෑම විටම නවතම භාවිතා කරන්න",
"Send changes only": "වෙනස්කම් පමණක් යවන්න",
"Receive changes only": "වෙනස්කම් පමණක් ලබන්න",
"Checksum Method": "පරීක්ෂණ ක්‍රමය",
"File Content (recommended)": "ගොනු අන්තර්ගතය (නිර්දේශිත)",
"File Name": "ගොනුවේ නම",
"Device Name": "උපකරණයේ නම",
"Connect to your KOReader Sync server.": "ඔබේ KOReader සමමුහුර්ත සේවාදායකයට සම්බන්ධ වන්න.",
"Server URL": "සේවාදායක URL",
"Username": "පරිශීලක නම",
"Your Username": "ඔබේ පරිශීලක නම",
"Password": "මුරපදය",
"Connect": "සම්බන්ධ කරන්න",
"Notebook": "සටහන් පොත",
"No notes match your search": "ඔබේ සෙවීමට ගැලපෙන සටහන් නැත",
"Excerpts": "උදාහරණ",
"Notes": "සටහන්",
"Add your notes here...": "මෙහි ඔබේ සටහන් එක් කරන්න...",
"Search notes and excerpts...": "සටහන් සහ උදාහරණ සොයන්න...",
"{{time}} min left in chapter": "පරිච්ඡේදයේ මිනිත්තු {{time}} ක් ඉතිරි",
"{{count}} pages left in chapter_one": "පරිච්ඡේදයේ පිටුව 1 ක් ඉතිරි",
"{{count}} pages left in chapter_other": "පරිච්ඡේදයේ පිටු {{count}} ක් ඉතිරි",
"Theme Mode": "තේමා ආකාරය",
"Invert Image In Dark Mode": "අඳුරු ආකාරයේදී රූපය පරිවර්තනය කරන්න",
"Override Book Color": "පොතේ වර්ණය අභිබවන්න",
"Theme Color": "තේමා වර්ණය",
"Custom": "අභිරුචි",
"Code Highlighting": "කේත ඉස්මතු කිරීම",
"Enable Highlighting": "ඉස්මතු කිරීම සක්‍රීය කරන්න",
"Code Language": "කේත භාෂාව",
"Auto": "ස්වයංක්‍රීය",
"Scroll": "අනුචලනය",
"Scrolled Mode": "අනුචලන ආකාරය",
"Continuous Scroll": "අඛණ්ඩ අනුචලනය",
"Overlap Pixels": "අතිච්ඡාදන පික්සල්",
"Click": "ක්ලික්",
"Clicks for Page Flip": "පිටු පෙරැළීම සඳහා ක්ලික්",
"Swap Clicks Area": "ක්ලික් ප්‍රදේශය මාරු කරන්න",
"Disable Double Click": "ද්විත්ව ක්ලික් අබල කරන්න",
"Volume Keys for Page Flip": "පිටු පෙරැළීම සඳහා ශබ්ද යතුර",
"Animation": "සජීවිකරණය",
"Paging Animation": "පිටු සජීවිකරණය",
"Security": "ආරක්ෂාව",
"Allow JavaScript": "JavaScript ට ඉඩ දෙන්න",
"Enable only if you trust the file.": "ගොනුව විශ්වාසදායක නම් පමණක් සක්‍රීය කරන්න.",
"Font": "අකුරු වර්ගය",
"Custom Fonts": "අභිරුචි අකුරු",
"Cancel Delete": "මකා දැමීම අවලංගු කරන්න",
"Delete Font": "අකුරු මකන්න",
"Import Font": "අකුරු ආයාත කරන්න",
"Tips": "ඉඟි",
"Supported font formats: .ttf, .otf, .woff, .woff2": "සහාය දක්වන අකුරු ආකෘති: .ttf, .otf, .woff, .woff2",
"Custom fonts can be selected from the Font Face menu": "Font Face මෙනුවෙන් අභිරුචි අකුරු තෝරා ගත හැක",
"Global Settings": "ගෝලීය සැකසුම්",
"Apply to All Books": "සියලු පොත් වලට අනුගත කරන්න",
"Apply to This Book": "මෙම පොතට අනුගත කරන්න",
"Reset Settings": "සැකසුම් නැවත සකස් කරන්න",
"Manage Custom Fonts": "අභිරුචි අකුරු කළමනාකරණය කරන්න",
"System Fonts": "පද්ධති අකුරු",
"Serif Font": "Serif අකුරු",
"Sans-Serif Font": "Sans-Serif අකුරු",
"Override Book Font": "පොතේ අකුරු අභිබවන්න",
"Font Size": "අකුරු ප්‍රමාණය",
"Default Font Size": "සාමාන්‍ය අකුරු ප්‍රමාණය",
"Minimum Font Size": "අවම අකුරු ප්‍රමාණය",
"Font Weight": "අකුරු බර",
"Font Family": "අකුරු පවුල",
"Default Font": "සාමාන්‍ය අකුරු",
"CJK Font": "CJK අකුරු",
"Font Face": "අකුරු මුහුණ",
"Monospace Font": "Monospace අකුරු",
"Language": "භාෂාව",
"Interface Language": "අතුරුමුහුණතේ භාෂාව",
"Translation": "පරිවර්තනය",
"Enable Translation": "පරිවර්තනය සක්‍රීය කරන්න",
"Show Source Text": "මූලාශ්‍ර පෙළ පෙන්වන්න",
"Translation Service": "පරිවර්තන සේවාව",
"Translate To": "වෙත පරිවර්තනය කරන්න",
"Override Book Layout": "පොතේ පිරිසැලසුම අභිබවන්න",
"Writing Mode": "ලේඛන ආකාරය",
"Default": "සාමාන්‍ය",
"Horizontal Direction": "තිරස් දිශාව",
"Vertical Direction": "සිරස් දිශාව",
"RTL Direction": "RTL දිශාව",
"Border Frame": "මායිම් රාමුව",
"Double Border": "ද්විත්ව මායිම",
"Border Color": "මායිම් වර්ණය",
"Paragraph": "ඡේදය",
"Paragraph Margin": "ඡේද මායිම",
"Line Spacing": "පේළි අන්තරය",
"Word Spacing": "වචන අන්තරය",
"Letter Spacing": "අකුරු අන්තරය",
"Text Indent": "පෙළ අභ්‍යන්තරය",
"Full Justification": "සම්පූර්ණ සාධාරණීකරණය",
"Hyphenation": "යොමු කිරීම",
"Page": "පිටුව",
"Top Margin (px)": "ඉහළ මායිම (px)",
"Bottom Margin (px)": "පහළ මායිම (px)",
"Left Margin (px)": "වම් මායිම (px)",
"Right Margin (px)": "දකුණු මායිම (px)",
"Column Gap (%)": "තීරු පරතරය (%)",
"Maximum Number of Columns": "අවරෝධ තීරු ගණන",
"Maximum Column Height": "අවරෝධ තීරු උස",
"Maximum Column Width": "අවරෝධ තීරු පළල",
"Header & Footer": "ශිර්ෂය සහ පාදය",
"Show Header": "ශිර්ෂය පෙන්වන්න",
"Show Footer": "පාදය පෙන්වන්න",
"Show Remaining Time": "ඉතිරි වේලාව පෙන්වන්න",
"Show Remaining Pages": "ඉතිරි පිටු පෙන්වන්න",
"Show Reading Progress": "කියවීමේ ප්‍රගතිය පෙන්වන්න",
"Reading Progress Style": "කියවීමේ ප්‍රගති ශෛලිය",
"Page Number": "පිටු අංකය",
"Percentage": "ප්‍රතිශතය",
"Apply also in Scrolled Mode": "අනුචලන ආකාරයේදීද අනුගත කරන්න",
"Screen": "තිරය",
"Orientation": "දිශානතිය",
"Portrait": "සිරස්",
"Landscape": "තිරස්",
"Apply": "අනුගත කරන්න",
"Custom Content CSS": "අභිරුචි අන්තර්ගත CSS",
"Enter CSS for book content styling...": "පොතේ අන්තර්ගත ශෛලිකරණය සඳහා CSS ඇතුල් කරන්න...",
"Custom Reader UI CSS": "අභිරුචි කියවන්නා UI CSS",
"Enter CSS for reader interface styling...": "කියවන්නා අතුරුමුහුණත ශෛලිකරණය සඳහා CSS ඇතුල් කරන්න...",
"Layout": "පිරිසැලසුම",
"Color": "වර්ණය",
"Behavior": "හැසිරීම",
"Reset {{settings}}": "{{settings}} නැවත සකස් කරන්න",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "සියලු ලෝකය වේදිකාවක්,\nසහ සියලු පුරුෂයන් සහ ගැහැණියන් හුදු රංගන ශිල්පීන්;\nඔවුන්ට පිටවීම් සහ ඇතුල්වීම් ඇත,\nසහ එක් මනුෂ්‍යයෙක් ඔහුගේ කාලයේ බොහෝ භාගයන් ඉටු කරයි,\nඔහුගේ ක්‍රියා වයස් හතකි.\n\n— විලියම් ශේක්ස්පියර්",
"(from 'As You Like It', Act II)": "('ඔබට අවශ්‍ය ලෙස', දෙවන අංකයෙන්)",
"Custom Theme": "අභිරුචි තේමාව",
"Theme Name": "තේමා නාමය",
"Text Color": "පෙළ වර්ණය",
"Background Color": "පසුබිම් වර්ණය",
"Link Color": "සබැඳි වර්ණය",
"Preview": "පෙර දසුන",
"Font & Layout": "අකුරු සහ පිරිසැලසුම",
"Book Cover": "පොත් ආවරණය",
"More Info": "තවත් තොරතුරු",
"Parallel Read": "සමාන්තර කියවීම",
"Disable": "අබල කරන්න",
"Enable": "සක්‍රීය කරන්න",
"KOReader Sync": "KOReader සමමුහුර්තය",
"Push Progress": "ප්‍රගතිය ඉදිරියට",
"Pull Progress": "ප්‍රගතිය අදින්න",
"Export Annotations": "සටහන් නිර්යාත කරන්න",
"Sort TOC by Page": "පිටුව අනුව TOC සකස් කරන්න",
"Edit": "සංස්කරණය",
"Search...": "සොයන්න...",
"Book": "පොත",
"Chapter": "පරිච්ඡේදය",
"Match Case": "අකුරු ගැළපීම",
"Match Whole Words": "සම්පූර්ණ වචන ගැළපීම",
"Match Diacritics": "ධ්වනි ලකුණු ගැළපීම",
"TOC": "සූචිය",
"Sidebar": "පැති තීරුව",
"Disable Translation": "පරිවර්තනය අබල කරන්න",
"Translation Not Available": "පරිවර්තනය ලබා ගත නොහැක",
"TTS not supported for PDF": "PDF සඳහා TTS සහාය නොදක්වයි",
"TTS not supported for this document": "මෙම ලේඛනය සඳහා TTS සහාය නොදක්වයි",
"No Timeout": "කාල සීමාවක් නැත",
"{{value}} minute": "මිනිත්තු {{value}}",
"{{value}} minutes": "මිනිත්තු {{value}}",
"{{value}} hour": "පැය {{value}}",
"{{value}} hours": "පැය {{value}}",
"Voices for {{lang}}": "{{lang}} සඳහා හඬ",
"Slow": "මන්දගාමී",
"Fast": "වේගවත්",
"{{engine}}: {{count}} voices_one": "{{engine}}: හඬ 1",
"{{engine}}: {{count}} voices_other": "{{engine}}: හඬ {{count}}",
"Sign in to Sync": "සමමුහුර්ත කිරීම සඳහා ඇතුල් වන්න",
"Synced at {{time}}": "{{time}} වේලාවේ සමමුහුර්ත විය",
"Never synced": "කිසි විටෙක සමමුහුර්ත නොවිණි",
"Reading Progress Synced": "කියවීමේ ප්‍රගතිය සමමුහුර්ත විය",
"Page {{page}} of {{total}} ({{percentage}}%)": "{{total}} න් {{page}} පිටුව ({{percentage}}%)",
"Current position": "වර්තමාන ස්ථානය",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "දළ වශයෙන් {{total}} න් {{page}} පිටුව ({{percentage}}%)",
"Approximately {{percentage}}%": "දළ වශයෙන් {{percentage}}%",
"Delete Your Account?": "ඔබේ ගිණුම මකන්නද?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "මෙම ක්‍රියාව අවලංගු කළ නොහැක. cloud හි ඔබේ සියලු දත්ත ස්ථිරවම මකා දැමෙනු ඇත.",
"Delete Permanently": "ස්ථිරවම මකන්න",
"Restore Purchase": "මිලදී ගැනීම ප්‍රතිෂ්ඨාපනය කරන්න",
"Manage Subscription": "දායකත්වය කළමනාකරණය කරන්න",
"Reset Password": "මුරපදය නැවත සකස් කරන්න",
"Sign Out": "ඉවත් වන්න",
"Delete Account": "ගිණුම මකන්න",
"Upgrade to {{plan}}": "{{plan}} වෙත උසස් කරන්න",
"Complete Your Subscription": "ඔබේ දායකත්වය සම්පූර්ණ කරන්න",
"Coming Soon": "ඉක්මනින් පැමිණේ",
"Upgrade to Plus or Pro": "Plus හෝ Pro වෙත උසස් කරන්න",
"Current Plan": "වර්තමාන සැලැස්ම",
"Plan Limits": "සැලැස්මේ සීමා",
"Failed to delete user. Please try again later.": "පරිශීලකයා මකා දැමීමට අසමත්. කරුණාකර පසුව නැවත උත්සාහ කරන්න.",
"Failed to create checkout session": "checkout session නිර්මාණය කිරීමට අසමත්",
"No purchases found to restore.": "ප්‍රතිෂ්ඨාපනය කිරීමට මිලදී ගැනීම් නොමැත.",
"Failed to restore purchases.": "මිලදී ගැනීම් ප්‍රතිෂ්ඨාපනය කිරීමට අසමත්.",
"Failed to manage subscription.": "දායකත්වය කළමනාකරණය කිරීමට අසමත්.",
"Failed to load subscription plans.": "දායකත්ව සැලැස්ම් පූරණය කිරීමට අසමත්.",
"Loading profile...": "පැතිකඩ පූරණය වෙමින්...",
"Processing your payment...": "ඔබේ ගෙවීම සැකසෙමින්...",
"Please wait while we confirm your subscription.": "අපි ඔබේ දායකත්වය තහවුරු කරන අතරතුර කරුණාකර රැඳී සිටින්න.",
"Payment Processing": "ගෙවීම් සැකසීම",
"Your payment is being processed. This usually takes a few moments.": "ඔබේ ගෙවීම සැකසෙමින් පවතී. මෙය සාමාන්‍යයෙන් මොහොතකින් සිදු වේ.",
"Payment Failed": "ගෙවීම අසමත්",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "අපට ඔබේ දායකත්වය සැකසීමට නොහැකි විය. කරුණාකර නැවත උත්සාහ කරන්න හෝ ගැටලුව දිගටම පවතින්නේ නම් සහාය සඳහා සම්බන්ධ වන්න.",
"Back to Profile": "පැතිකඩට ආපසු",
"Subscription Successful!": "දායකත්වය සාර්ථක!",
"Thank you for your subscription! Your payment has been processed successfully.": "ඔබේ දායකත්වයට ස්තූතිය! ඔබේ ගෙවීම සාර්ථකව සැකසිණි.",
"Email:": "ඊමේල්:",
"Plan:": "සැලැස්ම:",
"Amount:": "මුදල:",
"Subscription ID:": "දායකත්ව ID:",
"Go to Library": "පුස්තකාලයට යන්න",
"Need help? Contact our support team at support@readest.com": "උදව් අවශ්‍යද? support@readest.com හි අපගේ සහාය කණ්ඩායමට සම්බන්ධ වන්න",
"Free Plan": "නොමිලේ සැලැස්ම",
"month": "මාසය",
"year": "වර්ෂය",
"Cross-Platform Sync": "හරස් වේදිකා සමමුහුර්තය",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "ඔබේ සියලු උපකරණ හරහා ඔබේ පුස්තකාලය, ප්‍රගතිය, ඉස්මතු කිරීම් සහ සටහන් බාධාවකින් තොරව සමමුහුර්ත කරන්න - ඔබේ ස්ථානය නැවත කිසි දිනෙක නැති නොකරන්න.",
"Customizable Reading": "අභිරුචිකරණය කළ හැකි කියවීම",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "පරිපූර්ණ කියවීමේ අත්දැකීම සඳහා සකස් කළ හැකි අකුරු, පිරිසැලසුම්, තේමා සහ උසස් ප්‍රදර්ශන සැකසුම් සමඟ සෑම විස්තරයක්ම පුද්ගලීකරණය කරන්න.",
"AI Read Aloud": "AI පවසා කියවීම",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "ඔබේ පොත් ජීවයට ගෙන එන ස්වභාවික ශබ්ද සහිත AI හඬ සමඟ අත් නිදහස් කියවීම රස විඳින්න.",
"AI Translations": "AI පරිවර්තන",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Google, Azure, හෝ DeepL හි බලය සමඟ ඕනෑම පෙළක් ක්ෂණිකව පරිවර්තනය කරන්න - ඕනෑම භාෂාවකින් අන්තර්ගතය තේරුම් ගන්න.",
"Community Support": "ප්‍රජා සහාය",
"Connect with fellow readers and get help fast in our friendly community channels.": "සහෘදී පාඨකයන් සමඟ සම්බන්ධ වන්න සහ අපගේ මිත්‍රශීලී ප්‍රජා නාලිකා වල ඉක්මනින් උදව් ලබා ගන්න.",
"Cloud Sync Storage": "Cloud සමමුහුර්ත ගබඩාව",
"AI Translations (per day)": "AI පරිවර්තන (දිනකට)",
"Plus Plan": "Plus සැලැස්ම",
"Includes All Free Plan Benefits": "සියලු නොමිලේ සැලැස්මේ ප්‍රතිලාභ ඇතුළත්",
"Unlimited AI Read Aloud Hours": "අසීමිත AI පවසා කියවීමේ පැය",
"Listen without limits—convert as much text as you like into immersive audio.": "සීමාවකින් තොරව සවන් දෙන්න - ඔබට අවශ්‍ය තරම් පෙළ ගිලී යන ශ්‍රව්‍ය බවට පරිවර්තනය කරන්න.",
"More AI Translations": "තවත් AI පරිවර්තන",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "වැඩි දෛනික භාවිතය සහ උසස් විකල්ප සමඟ වැඩිදියුණු කළ පරිවර්තන හැකියාවන් අනවරණය කරන්න.",
"DeepL Pro Access": "DeepL Pro ප්‍රවේශය",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "ලබා ගත හැකි වඩාත් නිරවද්‍ය පරිවර්තන එන්ජිම සමඟ දිනකට අක්ෂර 100,000 ක් දක්වා පරිවර්තනය කරන්න.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "GB 5 ක් දක්වා cloud ගබඩාව සමඟ ඔබේ සම්පූර්ණ කියවීමේ එකතුව ආරක්ෂිතව ගබඩා කරන්න සහ ප්‍රවේශ වන්න.",
"Priority Support": "ප්‍රමුඛතා සහාය",
"Enjoy faster responses and dedicated assistance whenever you need help.": "ඔබට උදව් අවශ්‍ය වන විට ඉක්මන් ප්‍රතිචාර සහ කැප වූ සහාය රස විඳින්න.",
"Pro Plan": "Pro සැලැස්ම",
"Includes All Plus Plan Benefits": "සියලු Plus සැලැස්මේ ප්‍රතිලාභ ඇතුළත්",
"Early Feature Access": "පූර්ව විශේෂාංග ප්‍රවේශය",
"Be the first to explore new features, updates, and innovations before anyone else.": "අන් කවුරුන්ටත් වඩා පළමුව නව විශේෂාංග, යාවත්කාලීන සහ නවෝත්පාදන ගවේෂණය කරන්න.",
"Advanced AI Tools": "උසස් AI මෙවලම්",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "දක්ෂ කියවීම, පරිවර්තනය සහ අන්තර්ගත සොයාගැනීම සඳහා ප්‍රබල AI මෙවලම් භාවිතා කරන්න.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "ලබා ගත හැකි වඩාත් නිරවද්‍ය පරිවර්තන එන්ජිම සමඟ දිනකට අක්ෂර 500,000 ක් දක්වා පරිවර්තනය කරන්න.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "GB 20 ක් දක්වා cloud ගබඩාව සමඟ ඔබේ සම්පූර්ණ කියවීමේ එකතුව ආරක්ෂිතව ගබඩා කරන්න සහ ප්‍රවේශ වන්න.",
"Version {{version}}": "සංස්කරණය {{version}}",
"Check Update": "යාවත්කාලීන පරීක්ෂා කරන්න",
"Already the latest version": "දැනටමත් නවතම සංස්කරණය",
"Checking for updates...": "යාවත්කාලීන සඳහා පරීක්ෂා කරමින්...",
"Error checking for updates": "යාවත්කාලීන පරීක්ෂා කිරීමේ දෝෂය",
"Drop to Import Books": "පොත් ආයාත කිරීමට අතහරින්න",
"Terms of Service": "සේවා නියමයන්",
"Privacy Policy": "පෞද්ගලිකත්ව ප්‍රතිපත්තිය",
"Enter book title": "පොතේ නම ඇතුල් කරන්න",
"Subtitle": "උප නාමය",
"Enter book subtitle": "පොතේ උප නාමය ඇතුල් කරන්න",
"Enter author name": "කතුවරයාගේ නම ඇතුල් කරන්න",
"Series": "කතා මාලාව",
"Enter series name": "කතා මාලාවේ නම ඇතුල් කරන්න",
"Series Index": "කතා මාලා සූචිය",
"Enter series index": "කතා මාලා සූචිය ඇතුල් කරන්න",
"Total in Series": "කතා මාලාවේ මුළු ගණන",
"Enter total books in series": "කතා මාලාවේ මුළු පොත් ගණන ඇතුල් කරන්න",
"Publisher": "ප්‍රකාශක",
"Enter publisher": "ප්‍රකාශක ඇතුල් කරන්න",
"Publication Date": "ප්‍රකාශන දිනය",
"YYYY or YYYY-MM-DD": "YYYY හෝ YYYY-MM-DD",
"Identifier": "හඳුනාගැනීම",
"Subjects": "විෂයන්",
"Fiction, Science, History": "ප්‍රබන්ධ, විද්‍යාව, ඉතිහාසය",
"Description": "විස්තරය",
"Enter book description": "පොතේ විස්තරය ඇතුල් කරන්න",
"Change cover image": "ආවරණ රූපය වෙනස් කරන්න",
"Replace": "ආදේශ කරන්න",
"Unlock cover": "ආවරණය අගුළු ඇරින්න",
"Lock cover": "ආවරණය අගුළු දමන්න",
"Auto-Retrieve Metadata": "Metadata ස්වයංක්‍රීයව ලබා ගන්න",
"Auto-Retrieve": "ස්වයංක්‍රීයව ලබා ගන්න",
"Unlock all fields": "සියලු ක්ෂේත්‍ර අගුළු ඇරින්න",
"Unlock All": "සියල්ල අගුළු ඇරින්න",
"Lock all fields": "සියලු ක්ෂේත්‍ර අගුළු දමන්න",
"Lock All": "සියල්ල අගුළු දමන්න",
"Reset": "නැවත සකස් කරන්න",
"Are you sure to delete the selected book?": "ඔබට විශ්වාසද තෝරාගත් පොත මකා දැමීමට?",
"Are you sure to delete the cloud backup of the selected book?": "ඔබට විශ්වාසද තෝරාගත් පොතේ cloud backup මකා දැමීමට?",
"Are you sure to delete the local copy of the selected book?": "ඔබට විශ්වාසද තෝරාගත් පොතේ දේශීය පිටපත මකා දැමීමට?",
"Edit Metadata": "Metadata සංස්කරණය කරන්න",
"Book Details": "පොතේ විස්තර",
"Unknown": "නොදන්නා",
"Remove from Cloud & Device": "Cloud සහ උපකරණයෙන් ඉවත් කරන්න",
"Remove from Cloud Only": "Cloud එකෙන් පමණක් ඉවත් කරන්න",
"Remove from Device Only": "උපකරණයෙන් පමණක් ඉවත් කරන්න",
"Published": "ප්‍රකාශිතය",
"Updated": "යාවත්කාලීන කරන ලදී",
"Added": "එක් කරන ලදී",
"File Size": "ගොනු ප්‍රමාණය",
"No description available": "විස්තරයක් ලබා ගත නොහැක",
"Locked": "අගුළු දමන ලදී",
"Select Metadata Source": "Metadata මූලාශ්‍රය තෝරන්න",
"Keep manual input": "අතින් ඇතුල් කළ දේ තබාගන්න",
"A new version of Readest is available!": "Readest හි නව සංස්කරණයක් ලබා ගත හැක!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} ලබා ගත හැක (ස්ථාපිත සංස්කරණය {{currentVersion}}).",
"Download and install now?": "දැන් බාගන්න සහ ස්ථාපනය කරන්නද?",
"Downloading {{downloaded}} of {{contentLength}}": "{{contentLength}} න් {{downloaded}} බාගනිමින්",
"Download finished": "බාගැනීම සම්පූර්ණයි",
"DOWNLOAD & INSTALL": "බාගන්න සහ ස්ථාපනය කරන්න",
"Changelog": "වෙනස්කම් ලිපිය",
"Software Update": "මෘදුකාංග යාවත්කාලීන",
"What's New in Readest": "Readest හි නවතම දේ",
"Select Files": "ගොනු තෝරන්න",
"Select Image": "රූපය තෝරන්න",
"Select Video": "වීඩියෝව තෝරන්න",
"Select Audio": "ශ්‍රව්‍ය තෝරන්න",
"Select Fonts": "අකුරු තෝරන්න",
"Storage": "ගබඩාව",
"{{percentage}}% of Cloud Sync Space Used.": "Cloud සමමුහුර්ත ඉඩෙන් {{percentage}}% භාවිතා කර ඇත.",
"Translation Characters": "පරිවර්තන අක්ෂර",
"{{percentage}}% of Daily Translation Characters Used.": "දෛනික පරිවර්තන අක්ෂර වලින් {{percentage}}% භාවිතා කර ඇත.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "දෛනික පරිවර්තන කෝටාව ළඟා විය. AI පරිවර්තන භාවිතා කරමින් සිටීමට ඔබේ සැලැස්ම උසස් කරන්න.",
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
"Azure Translator": "Azure Translator",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Yandex Translate": "Yandex Translate",
"Gray": "අළු",
"Sepia": "සීපියා",
"Grass": "තණකොළ",
"Cherry": "චෙරි",
"Sky": "අහස",
"Solarized": "සූර්යකිරණ",
"Gruvbox": "Gruvbox",
"Nord": "Nord",
"Contrast": "පරස්පර",
"Sunset": "හිරු බැස්ම",
"Reveal in Finder": "Finder හි පෙන්වන්න",
"Reveal in File Explorer": "File Explorer හි පෙන්වන්න",
"Reveal in Folder": "ෆෝල්ඩරයේ පෙන්වන්න",
"Previous Paragraph": "කලින් ඡේදය",
"Previous Sentence": "කලින් වාක්‍යය",
"Pause": "විරාමය",
"Play": "වාදනය කරන්න",
"Next Sentence": "මීළඟ වාක්‍යය",
"Next Paragraph": "මීළඟ ඡේදය",
"Separate Cover Page": "වෙන් වූ ආවරණ පිටුව"
}
@@ -0,0 +1,526 @@
{
"Email address": "மின்னஞ்சல் முகவரி",
"Your Password": "உங்கள் கடவுச்சொல்",
"Your email address": "உங்கள் மின்னஞ்சல் முகவரி",
"Your password": "உங்கள் கடவுச்சொல்",
"Sign in": "உள்நுழையவும்",
"Signing in...": "உள்நுழைகிறது...",
"Sign in with {{provider}}": "{{provider}} மூலம் உள்நுழையவும்",
"Already have an account? Sign in": "ஏற்கனவே கணக்கு உள்ளதா? உள்நுழையவும்",
"Create a Password": "கடவுச்சொல் உருவாக்கவும்",
"Sign up": "பதிவு செய்யவும்",
"Signing up...": "பதிவு செய்கிறது...",
"Don't have an account? Sign up": "கணக்கு இல்லையா? பதிவு செய்யவும்",
"Check your email for the confirmation link": "உறுதிப்படுத்தல் இணைப்பிற்கு உங்கள் மின்னஞ்சலைச் சரிபார்க்கவும்",
"Signing in ...": "உள்நுழைகிறது...",
"Send a magic link email": "மேஜிக் இணைப்பு மின்னஞ்சல் அனுப்பவும்",
"Check your email for the magic link": "மேஜிக் இணைப்பிற்கு உங்கள் மின்னஞ்சலைச் சரிபார்க்கவும்",
"Send reset password instructions": "கடவுச்சொல் மீட்டமைப்பு வழிமுறைகளை அனுப்பவும்",
"Sending reset instructions ...": "மீட்டமைப்பு வழிமுறைகள் அனுப்புகிறது...",
"Forgot your password?": "கடவுச்சொல் மறந்துவிட்டதா?",
"Check your email for the password reset link": "கடவுச்சொல் மீட்டமைப்பு இணைப்பிற்கு மின்னஞ்சலைச் சரிபார்க்கவும்",
"Phone number": "தொலைபேசி எண்",
"Your phone number": "உங்கள் தொலைபேசி எண்",
"Token": "டோக்கன்",
"Your OTP token": "உங்கள் OTP டோக்கன்",
"Verify token": "டோக்கனை சரிபார்க்கவும்",
"Sign in with Google": "Google மூலம் உள்நுழையவும்",
"Sign in with Apple": "Apple மூலம் உள்நுழையவும்",
"Sign in with GitHub": "GitHub மூலம் உள்நுழையவும்",
"New Password": "புதிய கடவுச்சொல்",
"Your new password": "உங்கள் புதிய கடவுச்சொல்",
"Update password": "கடவுச்சொல் புதுப்பிக்கவும்",
"Updating password ...": "கடவுச்சொல் புதுப்பிக்கிறது...",
"Your password has been updated": "உங்கள் கடவுச்சொல் புதுப்பிக்கப்பட்டது",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "ஏதோ தவறு நடந்துள்ளது. கவலைப்பட வேண்டாம், எங்கள் குழு அறிவிக்கப்பட்டுள்ளது மற்றும் நாங்கள் தீர்வு செய்து கொண்டிருக்கிறோம்.",
"Error Details:": "பிழை விவரங்கள்:",
"Try Again": "மீண்டும் முயற்சிக்கவும்",
"Go Back": "பின் செல்லவும்",
"Your Library": "உங்கள் நூலகம்",
"Need help?": "உதவி தேவையா?",
"Contact Support": "ஆதரவை தொடர்பு கொள்ளவும்",
"Open": "திறக்கவும்",
"Group": "குழு",
"Details": "விவரங்கள்",
"Delete": "நீக்கவும்",
"Cancel": "ரத்து செய்யவும்",
"Confirm Deletion": "நீக்குதலை உறுதிசெய்யவும்",
"Are you sure to delete {{count}} selected book(s)?_one": "தேர்ந்தெடுக்கப்பட்ட புத்தகத்தை நீக்க உறுதியா?",
"Are you sure to delete {{count}} selected book(s)?_other": "தேர்ந்தெடுக்கப்பட்ட {{count}} புத்தகங்களை நீக்க உறுதியா?",
"Deselect Book": "புத்தகத்தை தேர்வுநீக்கவум்",
"Select Book": "புத்தகத்தை தேர்வு செய்யவும்",
"Show Book Details": "புத்தக விவரங்களைக் காட்டவும்",
"Download Book": "புத்தகத்தை பதிவிறக்கவும்",
"Upload Book": "புத்தகத்தை பதிவேற்றவும்",
"Deselect Group": "குழுவை தேர்வுநீক்கவும்",
"Select Group": "குழுவை தேர்வு செய்யவும்",
"Untitled Group": "பெயரிடப்படாத குழு",
"Group Books": "புத்தகங்களை குழுவாக்கவும்",
"Remove From Group": "குழுவிலிருந்து நீக்கவும்",
"Create New Group": "புதிய குழு உருவாக்கவும்",
"Save": "சேமிக்கவும்",
"Confirm": "உறுதிசெய்யவும்",
"From Local File": "உள்ளூர் கோப்பிலிருந்து",
"Search in {{count}} Book(s)..._one": "புத்தகத்தில் தேடவும்...",
"Search in {{count}} Book(s)..._other": "{{count}} புத்தகங்களில் தேடவும்...",
"Search Books...": "புத்தகங்களைத் தேடவும்...",
"Clear Search": "தேடலை அழிக்கவும்",
"Import Books": "புத்தகங்களை இறக்குமதி செய்யவும்",
"Select Multiple Books": "பல புத்தகங்களை தேர்வு செய்யவும்",
"Select Books": "புத்தகங்களை தேர்வு செய்யவும்",
"Deselect": "தேர்வுநீக்கவும்",
"Select All": "அனைத்தையும் தேர்வு செய்யவும்",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} ஆக உள்நுழைந்துள்ளீர்கள்",
"Logged in": "உள்நுழைந்துள்ளீர்கள்",
"Account": "கணக்கு",
"Sign In": "உள்நுழையவும்",
"Auto Upload Books to Cloud": "புத்தகங்களை cloud இல் தானாக பதிவேற்றவும்",
"Auto Import on File Open": "கோப்பு திறக்கும்போது தானாக இறக்குமதி",
"Open Last Book on Start": "தொடங்கும்போது கடைசி புத்தகத்தைத் திறக்கவும்",
"Check Updates on Start": "தொடங்கும்போது புதுப்பிப்புகளைச் சரிபார்க்கவும்",
"Open Book in New Window": "புதிய சாளரத்தில் புத்தகத்தைத் திறக்கவும்",
"Fullscreen": "முழுத்திரை",
"Always on Top": "எப்போதும் மேலே",
"Always Show Status Bar": "எப்போதும் நிலைப் பட்டியைக் காட்டவும்",
"Keep Screen Awake": "திரையை விழித்திருக்கச் செய்யவும்",
"Reload Page": "பக்கத்தை மீண்டும் ஏற்றவும்",
"Dark Mode": "இருட்டு பயன்முறை",
"Light Mode": "வெளிச்ச பயன்முறை",
"Auto Mode": "தானியங்கி பயன்முறை",
"Upgrade to Readest Premium": "Readest Premium க்கு மேம்படுத்தவும்",
"Download Readest": "Readest பதிவிறக்கவும்",
"About Readest": "Readest பற்றி",
"Help improve Readest": "Readest மேம்படுத்த உதவவும்",
"Sharing anonymized statistics": "அடையாள விவரங்களை நீக்கிய புள்ளிவிவரங்களைப் பகிர்தல்",
"List": "பட்டியல்",
"Grid": "கட்டம்",
"Crop": "வெட்டவும்",
"Fit": "பொருத்தவும்",
"Title": "தலைப்பு",
"Author": "ஆசிரியர்",
"Format": "வடிவம்",
"Date Read": "படித்த தேதி",
"Date Added": "சேர்த்த தேதி",
"Ascending": "ஏறுவரிசை",
"Descending": "இறங்குவரிசை",
"Book Covers": "புத்தக அட்டைகள்",
"Sort by...": "வரிசைப்படுத்து...",
"No supported files found. Supported formats: {{formats}}": "ஆதரிக்கப்படும் கோப்புகள் இல்லை. ஆதரிக்கப்படும் வடிவங்கள்: {{formats}}",
"No chapters detected.": "அத்தியாயங்கள் கண்டறியப்படவில்லை.",
"Failed to parse the EPUB file.": "EPUB கோப்பை பகுப்பாய்வு செய்ய முடியவில்லை.",
"This book format is not supported.": "இந்த புத்தக வடிவம் ஆதரிக்கப்படவில்லை.",
"Failed to import book(s): {{filenames}}": "புத்தகங்களை இறக்குமதி செய்ய முடியவில்லை: {{filenames}}",
"Book uploaded: {{title}}": "புத்தகம் பதிவேற்றப்பட்டது: {{title}}",
"Insufficient storage quota": "போதுமான சேமிப்பக ஒதுக்கீடு இல்லை",
"Failed to upload book: {{title}}": "புத்தகத்தை பதிவேற்ற முடியவில்லை: {{title}}",
"Book downloaded: {{title}}": "புத்தகம் பதிவிறக்கப்பட்டது: {{title}}",
"Failed to download book: {{title}}": "புத்தகத்தை பதிவிறக்க முடியவில்லை: {{title}}",
"Book deleted: {{title}}": "புத்தகம் நீக்கப்பட்டது: {{title}}",
"Deleted cloud backup of the book: {{title}}": "புத்தகத்தின் cloud backup நீக்கப்பட்டது: {{title}}",
"Deleted local copy of the book: {{title}}": "புத்தகத்தின் உள்ளூர் நகல் நீக்கப்பட்டது: {{title}}",
"Failed to delete book: {{title}}": "புத்தகத்தை நீக்க முடியவில்லை: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "புத்தகத்தின் cloud backup நீக்க முடியவில்லை: {{title}}",
"Failed to delete local copy of the book: {{title}}": "புத்தகத்தின் உள்ளூர் நகல் நீக்க முடியவில்லை: {{title}}",
"Welcome to your library. You can import your books here and read them anytime.": "உங்கள் நூலகத்திற்கு வரவேற்கிறோம். நீங்கள் இங்கே உங்கள் புத்தகங்களை இறக்குமதி செய்து எப்போது வேண்டுமானாலும் படிக்கலாம்.",
"Copied to notebook": "குறிப்பேட்டில் நகலெடுக்கப்பட்டது",
"No annotations to export": "ஏற்றுமதி செய்ய குறிப்புகள் இல்லை",
"Exported from Readest": "Readest இல் இருந்து ஏற்றுமதி செய்யப்பட்டது",
"Highlights & Annotations": "முத்திரைகள் மற்றும் குறிப்புகள்",
"Untitled": "பெயரிடப்படாதது",
"Note": "குறிப்பு",
"Copied to clipboard": "clipboard இல் நகலெடுக்கப்பட்டது",
"Copy": "நகல்",
"Delete Highlight": "முத்திரையை நீக்கவும்",
"Highlight": "முத்திரை",
"Annotate": "குறிப்பிடவும்",
"Search": "தேடவும்",
"Dictionary": "அகராதி",
"Wikipedia": "விக்கிபீடியா",
"Translate": "மொழிபெயர்க்கவும்",
"Speak": "பேசவும்",
"Login Required": "உள்நுழைவு தேவை",
"Quota Exceeded": "ஒதுக்கீடு மீறப்பட்டது",
"Unable to fetch the translation. Please log in first and try again.": "மொழிபெயர்ப்பைப் பெற முடியவில்லை. முதலில் உள்நுழைந்து மீண்டும் முயற்சிக்கவும்.",
"Unable to fetch the translation. Try again later.": "மொழிபெயர்ப்பைப் பெற முடியவில்லை. பின்னர் மீண்டும் முயற்சிக்கவும்.",
"Original Text": "மூல உரை",
"Auto Detect": "தானாக கண்டறிதல்",
"(detected)": "(கண்டறியப்பட்டது)",
"Translated Text": "மொழிபெயர்க்கப்பட்ட உரை",
"System Language": "கணினி மொழி",
"Loading...": "ஏற்றுகிறது...",
"No translation available.": "மொழிபெயர்ப்பு கிடைக்கவில்லை.",
"Translated by {{provider}}.": "{{provider}} மூலம் மொழிபெயர்க்கப்பட்டது.",
"Bookmark": "புக்மார்க்",
"Next Section": "அடுத்த பிரிவு",
"Previous Section": "முந்தைய பிரிவு",
"Next Page": "அடுத்த பக்கம்",
"Previous Page": "முந்தைய பக்கம்",
"Go Forward": "முன்னோக்கி செல்லவும்",
"Small": "சிறிய",
"Large": "பெரிய",
"Sync Conflict": "ஒத்திசைவு முரண்பாடு",
"Sync reading progress from \"{{deviceName}}\"?": "\"{{deviceName}}\" இல் இருந்து வாசிப்பு முன்னேற்றத்தை ஒத்திசைக்கவா?",
"another device": "மற்றொரு சாதனம்",
"Local Progress": "உள்ளூர் முன்னேற்றம்",
"Remote Progress": "தொலை முன்னேற்றம்",
"Failed to connect": "இணைக்க முடியவில்லை",
"Disconnected": "துண்டிக்கப்பட்டது",
"KOReader Sync Settings": "KOReader ஒத்திசைவு அமைப்புகள்",
"Sync as {{userDisplayName}}": "{{userDisplayName}} ஆக ஒத்திசைக்கவும்",
"Sync Server Connected": "ஒத்திசைவு சர்வர் இணைக்கப்பட்டது",
"Sync Strategy": "ஒத்திசைவு உத்தி",
"Ask on conflict": "முரண்பாட்டில் கேட்கவும்",
"Always use latest": "எப்போதும் சமீபத்தியதைப் பயன்படுத்தவும்",
"Send changes only": "மாற்றங்களை மட்டும் அனுப்பவும்",
"Receive changes only": "மாற்றங்களை மட்டும் பெறவும்",
"Checksum Method": "சரிபார்ப்பு முறை",
"File Content (recommended)": "கோப்பு உள்ளடக்கம் (பரிந்துரைக்கப்பட்டது)",
"File Name": "கோப்பு பெயர்",
"Device Name": "சாதன பெயர்",
"Connect to your KOReader Sync server.": "உங்கள் KOReader ஒத்திசைவு சர்வருடன் இணையவும்.",
"Server URL": "சர்வர் URL",
"Username": "பயனர்பெயர்",
"Your Username": "உங்கள் பயனர்பெயர்",
"Password": "கடவுச்சொல்",
"Connect": "இணைக்கவும்",
"Notebook": "குறிப்பேடு",
"No notes match your search": "உங்கள் தேடலுக்கு பொருந்தும் குறிப்புகள் இல்லை",
"Excerpts": "உதாரணங்கள்",
"Notes": "குறிப்புகள்",
"Add your notes here...": "உங்கள் குறிப்புகளை இங்கே சேர்க்கவும்...",
"Search notes and excerpts...": "குறிப்புகள் மற்றும் உதாரணங்களைத் தேடவும்...",
"{{time}} min left in chapter": "அத்தியாயத்தில் {{time}} நிமிடங்கள் மீதமுள்ளன",
"{{count}} pages left in chapter_one": "அத்தியாயத்தில் 1 பக்கம் மீதமுள்ளது",
"{{count}} pages left in chapter_other": "அத்தியாயத்தில் {{count}} பக்கங்கள் மீதமுள்ளன",
"Theme Mode": "தீம் பயன்முறை",
"Invert Image In Dark Mode": "இருட்டு பயன்முறையில் படத்தை தலைகீழாக மாற்றவும்",
"Override Book Color": "புத்தக நிறத்தை மேலெழுதவும்",
"Theme Color": "தீம் நிறம்",
"Custom": "தனிப்பயன்",
"Code Highlighting": "குறியீடு முத்திரை",
"Enable Highlighting": "முத்திரையிடுதலை இயக்கவும்",
"Code Language": "குறியீடு மொழி",
"Auto": "தானியங்கி",
"Scroll": "உருட்டவும்",
"Scrolled Mode": "உருட்டல் பயன்முறை",
"Continuous Scroll": "தொடர்ச்சியான உருட்டல்",
"Overlap Pixels": "ஒன்றுடன் ஒன்று பிக்சல்கள்",
"Click": "கிளிக்",
"Clicks for Page Flip": "பக்கம் புரட்டுவதற்கு கிளிக்கள்",
"Swap Clicks Area": "கிளிக் பகுதியை மாற்றவும்",
"Disable Double Click": "இரட்டை கிளிக்கை முடக்கவும்",
"Volume Keys for Page Flip": "பக்கம் புரட்டுவதற்கு ஒலி விசைகள்",
"Animation": "அனிமேஷன்",
"Paging Animation": "பக்க அனிமேஷன்",
"Security": "பாதுகாப்பு",
"Allow JavaScript": "JavaScript அனுமதிக்கவும்",
"Enable only if you trust the file.": "கோப்பை நம்பினால் மட்டும் இயக்கவும்.",
"Font": "எழுத்துரு",
"Custom Fonts": "தனிப்பயன் எழுத்துருக்கள்",
"Cancel Delete": "நீக்குதலை ரத்துசெய்யவும்",
"Delete Font": "எழுத்துருவை நீக்கவும்",
"Import Font": "எழுத்துருவை இறக்குமதி செய்யவும்",
"Tips": "குறிப்புகள்",
"Supported font formats: .ttf, .otf, .woff, .woff2": "ஆதரிக்கப்படும் எழுத்துரு வடிவங்கள்: .ttf, .otf, .woff, .woff2",
"Custom fonts can be selected from the Font Face menu": "Font Face மெனுவிலிருந்து தனிப்பயன் எழுத்துருக்களை தேர்வு செய்யலாம்",
"Global Settings": "உலகளாவிய அமைப்புகள்",
"Apply to All Books": "அனைத்து புத்தகங்களுக்கும் பொருந்தும்",
"Apply to This Book": "இந்த புத்தகத்திற்கு பொருந்தும்",
"Reset Settings": "அமைப்புகளை மீட்டமைக்கவும்",
"Manage Custom Fonts": "தனிப்பயன் எழுத்துருக்களை நிர்வகிக்கவும்",
"System Fonts": "கணினி எழுத்துருக்கள்",
"Serif Font": "Serif எழுத்துரு",
"Sans-Serif Font": "Sans-Serif எழுத்துரு",
"Override Book Font": "புத்தக எழுத்துருவை மேலெழுதவும்",
"Font Size": "எழுத்துரு அளவு",
"Default Font Size": "இயல்புநிலை எழுத்துரு அளவு",
"Minimum Font Size": "குறைந்தபட்ச எழுத்துரு அளவு",
"Font Weight": "எழுத்துரு எடை",
"Font Family": "எழுத்துரு குடும்பம்",
"Default Font": "இயல்புநிலை எழுத்துரு",
"CJK Font": "CJK எழுத்துரு",
"Font Face": "எழுத்துரு முகம்",
"Monospace Font": "Monospace எழுத்துரு",
"Language": "மொழி",
"Interface Language": "இடைமுக மொழி",
"Translation": "மொழிபெயர்ப்பு",
"Enable Translation": "மொழிபெயர்ப்பை இயக்கவும்",
"Show Source Text": "மூல உரையைக் காட்டவும்",
"Translation Service": "மொழிபெயர்ப்பு சேவை",
"Translate To": "இதற்கு மொழிபெயர்க்கவும்",
"Override Book Layout": "புத்தக அமைப்பை மேலெழுதவும்",
"Writing Mode": "எழுதும் பயன்முறை",
"Default": "இயல்புநிலை",
"Horizontal Direction": "கிடைமட்ட திசை",
"Vertical Direction": "செங்குத்து திசை",
"RTL Direction": "RTL திசை",
"Border Frame": "எல்லை சட்டகம்",
"Double Border": "இரட்டை எல்லை",
"Border Color": "எல்லை நிறம்",
"Paragraph": "பத்தி",
"Paragraph Margin": "பத்தி விளிம்பு",
"Line Spacing": "வரி இடைவெளி",
"Word Spacing": "சொல் இடைவெளி",
"Letter Spacing": "எழுத்து இடைவெளி",
"Text Indent": "உரை உள்தள்ளல்",
"Full Justification": "முழு நியாயம்",
"Hyphenation": "இணைக்கோடு",
"Page": "பக்கம்",
"Top Margin (px)": "மேல் விளிம்பு (px)",
"Bottom Margin (px)": "கீழ் விளிம்பு (px)",
"Left Margin (px)": "இடது விளிம்பு (px)",
"Right Margin (px)": "வலது விளிம்பு (px)",
"Column Gap (%)": "நெடுவரிசை இடைவெளி (%)",
"Maximum Number of Columns": "அதிகபட்ச நெடுவரிசை எண்ணிக்கை",
"Maximum Column Height": "அதிகபட்ச நெடுவரிசை உயரம்",
"Maximum Column Width": "அதிகபட்ச நெடுவரிசை அகலம்",
"Header & Footer": "தலைப்பு மற்றும் அடிக்குறிப்பு",
"Show Header": "தலைப்பைக் காட்டவும்",
"Show Footer": "அடிக்குறிப்பைக் காட்டவும்",
"Show Remaining Time": "மீதமுள்ள நேரத்தைக் காட்டவும்",
"Show Remaining Pages": "மீதமுள்ள பக்கங்களைக் காட்டவும்",
"Show Reading Progress": "வாசிப்பு முன்னேற்றத்தைக் காட்டவும்",
"Reading Progress Style": "வாசிப்பு முன்னேற்ற பாணி",
"Page Number": "பக்க எண்",
"Percentage": "சதவீதம்",
"Apply also in Scrolled Mode": "உருட்டல் பயன்முறையிலும் பொருந்தும்",
"Screen": "திரை",
"Orientation": "திசைநிலை",
"Portrait": "நிலைமான்",
"Landscape": "கிடைமான்",
"Apply": "பொருந்தும்",
"Custom Content CSS": "தனிப்பயன் உள்ளடக்க CSS",
"Enter CSS for book content styling...": "புத்தக உள்ளடக்க வடிவமைப்பிற்கு CSS ஐ உள்ளிடவும்...",
"Custom Reader UI CSS": "தனிப்பயன் வாசிப்பான் UI CSS",
"Enter CSS for reader interface styling...": "வாசிப்பான் இடைமுக வடிவமைப்பிற்கு CSS ஐ உள்ளிடவும்...",
"Layout": "அமைப்பு",
"Color": "நிறம்",
"Behavior": "நடத்தை",
"Reset {{settings}}": "{{settings}} மீட்டமைக்கவும்",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "உலகம் முழுவதும் ஒரு மேடை,\nஅதில் ஆண்களும் பெண்களும் வெறும் நடிகர்கள்;\nஅவர்களுக்கு வெளியேறுதலும் நுழைவும் உண்டு,\nஒரு மனிதன் தன் காலத்தில் பல பாத்திரங்களை வகிக்கிறான்,\nஅவனுடைய செயல்கள் ஏழு வயதுகளாக இருக்கின்றன.\n\n— வில்லியம் ஷேக்ஸ்பியர்",
"(from 'As You Like It', Act II)": "('ஆஸ் யூ லைக் இட்', இரண்டாவது அங்கத்திலிருந்து)",
"Custom Theme": "தனிப்பயன் தீம்",
"Theme Name": "தீம் பெயர்",
"Text Color": "உரை நிறம்",
"Background Color": "பின்னணி நிறம்",
"Link Color": "இணைப்பு நிறம்",
"Preview": "முன்னோட்டம்",
"Font & Layout": "எழுத்துரு மற்றும் அமைப்பு",
"Book Cover": "புத்தக அட்டை",
"More Info": "மேலும் தகவல்",
"Parallel Read": "இணை வாசிப்பு",
"Disable": "முடக்கவும்",
"Enable": "இயக்கவும்",
"KOReader Sync": "KOReader ஒத்திசைவு",
"Push Progress": "முன்னேற்றத்தை அனுப்பவும்",
"Pull Progress": "முன்னேற்றத்தை இழுக்கவும்",
"Export Annotations": "குறிப்புகளை ஏற்றுமதி செய்யவும்",
"Sort TOC by Page": "பக்கத்தின்படி TOC வரிசைப்படுத்தவும்",
"Edit": "திருத்தவும்",
"Search...": "தேடவும்...",
"Book": "புத்தகம்",
"Chapter": "அத்தியாயம்",
"Match Case": "எழுத்து பொருத்தம்",
"Match Whole Words": "முழு வார்த்தைகள் பொருத்தம்",
"Match Diacritics": "உச்சரிப்பு குறிகள் பொருத்தம்",
"TOC": "உள்ளடக்கம்",
"Sidebar": "பக்கப்பட்டி",
"Disable Translation": "மொழிபெயர்ப்பை முடக்கவும்",
"Translation Not Available": "மொழிபெயர்ப்பு கிடைக்கவில்லை",
"TTS not supported for PDF": "PDF க்கு TTS ஆதரிக்கப்படவில்லை",
"TTS not supported for this document": "இந்த ஆவணத்திற்கு TTS ஆதரிக்கப்படவில்லை",
"No Timeout": "நேர வரம்பு இல்லை",
"{{value}} minute": "{{value}} நிமிடம்",
"{{value}} minutes": "{{value}} நிமிடங்கள்",
"{{value}} hour": "{{value}} மணிநேரம்",
"{{value}} hours": "{{value}} மணிநேரங்கள்",
"Voices for {{lang}}": "{{lang}} க்கான குரல்கள்",
"Slow": "மெதுவாக",
"Fast": "வேகமாக",
"{{engine}}: {{count}} voices_one": "{{engine}}: 1 குரல்",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} குரல்கள்",
"Sign in to Sync": "ஒத்திசைக்க உள்நுழையவும்",
"Synced at {{time}}": "{{time}} இல் ஒத்திசைக்கப்பட்டது",
"Never synced": "ஒத்திசைக்கப்படவில்லை",
"Reading Progress Synced": "வாசிப்பு முன்னேற்றம் ஒத்திசைக்கப்பட்டது",
"Page {{page}} of {{total}} ({{percentage}}%)": "{{total}} இல் {{page}} பக்கம் ({{percentage}}%)",
"Current position": "தற்போதைய நிலை",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "தோராயமாக {{total}} இல் {{page}} பக்கம் ({{percentage}}%)",
"Approximately {{percentage}}%": "தோராயமாக {{percentage}}%",
"Delete Your Account?": "உங்கள் கணக்கை நீக்கவா?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "இந்த செயலை மாற்ற முடியாது. cloud இல் உள்ள உங்கள் எல்லா தரவும் நிரந்தரமாக நீக்கப்படும்.",
"Delete Permanently": "நிரந்தரமாக நீக்கவும்",
"Restore Purchase": "வாங்குதலை மீட்டெடுக்கவும்",
"Manage Subscription": "சந்தாவை நிர்வகிக்கவும்",
"Reset Password": "கடவுச்சொல் மீட்டமைக்கவும்",
"Sign Out": "வெளியேறவும்",
"Delete Account": "கணக்கை நீக்கவும்",
"Upgrade to {{plan}}": "{{plan}} க்கு மேம்படுத்தவும்",
"Complete Your Subscription": "உங்கள் சந்தாவை நிறைவு செய்யவும்",
"Coming Soon": "விரைவில் வரும்",
"Upgrade to Plus or Pro": "Plus அல்லது Pro க்கு மேம்படுத்தவும்",
"Current Plan": "தற்போதைய திட்டம்",
"Plan Limits": "திட்ட வரம்புகள்",
"Failed to delete user. Please try again later.": "பயனரை நீக்க முடியவில்லை. பின்னர் மீண்டும் முயற்சிக்கவும்.",
"Failed to create checkout session": "checkout session உருவாக்க முடியவில்லை",
"No purchases found to restore.": "மீட்டெடுக்க வாங்குதல்கள் இல்லை.",
"Failed to restore purchases.": "வாங்குதல்களை மீட்டெடுக்க முடியவில்லை.",
"Failed to manage subscription.": "சந்தாவை நிர்வகிக்க முடியவில்லை.",
"Failed to load subscription plans.": "சந்தா திட்டங்களை ஏற்ற முடியவில்லை.",
"Loading profile...": "சுயவிவரம் ஏற்றுகிறது...",
"Processing your payment...": "உங்கள் கட்டணத்தை செயல்படுத்துகிறது...",
"Please wait while we confirm your subscription.": "நாங்கள் உங்கள் சந்தாவை உறுதிசெய்யும் வரை காத்திருக்கவும்.",
"Payment Processing": "கட்டண செயலாக்கம்",
"Your payment is being processed. This usually takes a few moments.": "உங்கள் கட்டணம் செயல்படுத்தப்படுகிறது. இது பொதுவாக சில நிமிடங்கள் ஆகும்.",
"Payment Failed": "கட்டணம் தோல்வியடைந்தது",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "உங்கள் சந்தாவை செயல்படுத்த முடியவில்லை. மீண்டும் முயற்சிக்கவும் அல்லது பிரச்சனை தொடர்ந்தால் ஆதரவைத் தொடர்பு கொள்ளவும்.",
"Back to Profile": "சுயவிவரத்திற்கு திரும்பவும்",
"Subscription Successful!": "சந்தா வெற்றிகரமானது!",
"Thank you for your subscription! Your payment has been processed successfully.": "உங்கள் சந்தாவிற்கு நன்றி! உங்கள் கட்டணம் வெற்றிகரமாக செயல்படுத்தப்பட்டது.",
"Email:": "மின்னஞ்சல்:",
"Plan:": "திட்டம்:",
"Amount:": "தொகை:",
"Subscription ID:": "சந்தா ID:",
"Go to Library": "நூலகத்திற்கு செல்லவும்",
"Need help? Contact our support team at support@readest.com": "உதவி தேவையா? support@readest.com இல் எங்கள் ஆதரவு குழுவைத் தொடர்பு கொள்ளவும்",
"Free Plan": "இலவச திட்டம்",
"month": "மாதம்",
"year": "வருடம்",
"Cross-Platform Sync": "குறுக்கு தளம் ஒத்திசைவு",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "உங்கள் அனைத்து சாதனங்களிலும் உங்கள் நூலகம், முன்னேற்றம், முத்திரைகள் மற்றும் குறிப்புகளை தடையின்றி ஒத்திசைக்கவும் - உங்கள் இடத்தை மீண்டும் இழக்க வேண்டாம்.",
"Customizable Reading": "தனிப்பயனாக்கக்கூடிய வாசிப்பு",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "சரியான வாசிப்பு அனுபவத்திற்காக சரிசெய்யக்கூடிய எழுத்துருக்கள், அமைப்புகள், தீம்கள் மற்றும் மேம்பட்ட காட்சி அமைப்புகளுடன் ஒவ்வொரு விவரத்தையும் தனிப்பயனாக்கவும்.",
"AI Read Aloud": "AI உரை வாசிப்பு",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "உங்கள் புத்தகங்களை உயிர்ப்பிக்கும் இயற்கையான ஒலியுடைய AI குரல்களுடன் கைகள் இல்லாமல் வாசிப்பை அனுபவிக்கவும்.",
"AI Translations": "AI மொழிபெயர்ப்புகள்",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Google, Azure அல்லது DeepL இன் சக்தியுடன் எந்த உரையையும் உடனடியாக மொழிபெயர்க்கவும் - எந்த மொழியிலும் உள்ளடக்கத்தை புரிந்துகொள்ளவும்.",
"Community Support": "சமூக ஆதரவு",
"Connect with fellow readers and get help fast in our friendly community channels.": "சக வாசகர்களுடன் இணையுங்கள் மற்றும் எங்கள் நட்பு சமூக சேனல்களில் விரைவாக உதவி பெறுங்கள்.",
"Cloud Sync Storage": "Cloud ஒத்திசைவு சேமிப்பு",
"AI Translations (per day)": "AI மொழிபெயர்ப்புகள் (ஒரு நாளுக்கு)",
"Plus Plan": "Plus திட்டம்",
"Includes All Free Plan Benefits": "அனைத்து இலவச திட்ட நன்மைகளும் உள்ளடங்கும்",
"Unlimited AI Read Aloud Hours": "வரம்பற்ற AI வாசிப்பு மணிநேரங்கள்",
"Listen without limits—convert as much text as you like into immersive audio.": "வரம்பு இல்லாமல் கேளுங்கள் - நீங்கள் விரும்பும் அளவு உரையை அழுத்தமான ஆடியோவாக மாற்றுங்கள்.",
"More AI Translations": "மேலும் AI மொழிபெயர்ப்புகள்",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "அதிக தினசரி பயன்பாடு மற்றும் மேம்பட்ட விருப்பங்களுடன் மேம்பட்ட மொழிபெயர்ப்பு திறன்களை திறக்கவும்.",
"DeepL Pro Access": "DeepL Pro அணுகல்",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "கிடைக்கக்கூடிய மிகவும் துல்லியமான மொழிபெயர்ப்பு இயந்திரத்துடன் தினசரி 100,000 எழுத்துகள் வரை மொழிபெயர்க்கவும்.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "5 GB cloud சேமிப்புடன் உங்கள் முழு வாசிப்பு சேகரிப்பையும் பாதுகாப்பாக சேமித்து அணுகவும்.",
"Priority Support": "முன்னுரிமை ஆதரவு",
"Enjoy faster responses and dedicated assistance whenever you need help.": "உங்களுக்கு உதவி தேவைப்படும் போதெல்லாம் விரைவான பதில்கள் மற்றும் அர்ப்பணிப்புள்ள உதவியை அனுபவிக்கவும்.",
"Pro Plan": "Pro திட்டம்",
"Includes All Plus Plan Benefits": "அனைத்து Plus திட்ட நன்மைகளும் உள்ளடங்கும்",
"Early Feature Access": "முந்தைய அம்சம் அணுகல்",
"Be the first to explore new features, updates, and innovations before anyone else.": "மற்றவர்களுக்கு முன்பே புதிய அம்சங்கள், புதுப்பிப்புகள் மற்றும் கண்டுபிடிப்புகளை ஆராயும் முதல் நபராக இருங்கள்.",
"Advanced AI Tools": "மேம்பட்ட AI கருவிகள்",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "புத்திசாலித்தனமான வாசிப்பு, மொழிபெயர்ப்பு மற்றும் உள்ளடக்க கண்டுபிடிப்புக்காக சக்திவாய்ந்த AI கருவிகளைப் பயன்படுத்துங்கள்.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "கிடைக்கக்கூடிய மிகவும் துல்லியமான மொழிபெயர்ப்பு இயந்திரத்துடன் தினசரி 500,000 எழுத்துகள் வரை மொழிபெயர்க்கவும்.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "20 GB cloud சேமிப்புடன் உங்கள் முழு வாசிப்பு சேகரிப்பையும் பாதுகாப்பாக சேமித்து அணுகவும்.",
"Version {{version}}": "பதிப்பு {{version}}",
"Check Update": "புதுப்பிப்பு சரிபார்க்கவும்",
"Already the latest version": "ஏற்கனவே சமீபத்திய பதிப்பு",
"Checking for updates...": "புதுப்பிப்புகளுக்கு சரிபார்க்கிறது...",
"Error checking for updates": "புதுப்பிப்புகள் சரிபார்க்கும்போது பிழை",
"Drop to Import Books": "புத்தகங்களை இறக்குமதி செய்ய இழுத்து விடவும்",
"Terms of Service": "சேவை விதிமுறைகள்",
"Privacy Policy": "தனியுரிமைக் கொள்கை",
"Enter book title": "புத்தக தலைப்பை உள்ளிடவும்",
"Subtitle": "துணைத்தலைப்பு",
"Enter book subtitle": "புத்தக துணைத்தலைப்பை உள்ளிடவும்",
"Enter author name": "ஆசிரியர் பெயரை உள்ளிடவும்",
"Series": "தொடர்",
"Enter series name": "தொடர் பெயரை உள்ளிடவும்",
"Series Index": "தொடர் குறியீடு",
"Enter series index": "தொடர் குறியீட்டை உள்ளிடவும்",
"Total in Series": "தொடரில் மொத்தம்",
"Enter total books in series": "தொடரில் மொத்த புத்தகங்களை உள்ளிடவும்",
"Publisher": "வெளியீட்டாளர்",
"Enter publisher": "வெளியீட்டாளரை உள்ளிடவும்",
"Publication Date": "வெளியீட்டு தேதி",
"YYYY or YYYY-MM-DD": "YYYY அல்லது YYYY-MM-DD",
"Identifier": "அடையாளங்காட்டி",
"Subjects": "பாடங்கள்",
"Fiction, Science, History": "கற்பனை, அறிவியல், வரலாறு",
"Description": "விளக்கம்",
"Enter book description": "புத்தக விளக்கத்தை உள்ளிடவும்",
"Change cover image": "அட்டை படத்தை மாற்றவும்",
"Replace": "மாற்றவும்",
"Unlock cover": "அட்டையை திறக்கவும்",
"Lock cover": "அட்டையை பூட்டவும்",
"Auto-Retrieve Metadata": "Metadata ஐ தானாக பெறவும்",
"Auto-Retrieve": "தானாக பெறவும்",
"Unlock all fields": "அனைத்து புலங்களையும் திறக்கவும்",
"Unlock All": "அனைத்தையும் திறக்கவும்",
"Lock all fields": "அனைத்து புலங்களையும் பூட்டவும்",
"Lock All": "அனைத்தையும் பூட்டவும்",
"Reset": "மீட்டமைக்கவும்",
"Are you sure to delete the selected book?": "தேர்ந்தெடுக்கப்பட்ட புத்தகத்தை நீக்க உறுதியா?",
"Are you sure to delete the cloud backup of the selected book?": "தேர்ந்தெடுக்கப்பட்ட புத்தகத்தின் cloud backup ஐ நீக்க உறுதியா?",
"Are you sure to delete the local copy of the selected book?": "தேர்ந்தெடுக்கப்பட்ட புத்தகத்தின் உள்ளூர் நகலை நீக்க உறுதியா?",
"Edit Metadata": "Metadata ஐ திருத்தவும்",
"Book Details": "புத்தக விவரங்கள்",
"Unknown": "தெரியாதது",
"Remove from Cloud & Device": "Cloud மற்றும் சாதனத்திலிருந்து நீக்கவும்",
"Remove from Cloud Only": "Cloud இலிருந்து மட்டும் நீக்கவும்",
"Remove from Device Only": "சாதனத்திலிருந்து மட்டும் நீக்கவும்",
"Published": "வெளியிடப்பட்டது",
"Updated": "புதுப்பிக்கப்பட்டது",
"Added": "சேர்க்கப்பட்டது",
"File Size": "கோப்பு அளவு",
"No description available": "விளக்கம் கிடைக்கவில்லை",
"Locked": "பூட்டப்பட்டது",
"Select Metadata Source": "Metadata மூலத்தைத் தேர்வுசெய்யவும்",
"Keep manual input": "கைமுறை உள்ளீட்டைத் தக்கவைக்கவும்",
"A new version of Readest is available!": "Readest இன் புதிய பதிப்பு கிடைக்கிறது!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} கிடைக்கிறது (நிறுவப்பட்ட பதிப்பு {{currentVersion}}).",
"Download and install now?": "இப்போது பதிவிறக்கி நிறுவ வேண்டுமா?",
"Downloading {{downloaded}} of {{contentLength}}": "{{contentLength}} இல் {{downloaded}} பதிவிறக்கம்",
"Download finished": "பதிவிறக்கம் முடிந்தது",
"DOWNLOAD & INSTALL": "பதிவிறக்கம் மற்றும் நிறுவல்",
"Changelog": "மாற்ற பதிவு",
"Software Update": "மென்பொருள் புதுப்பிப்பு",
"What's New in Readest": "Readest இல் புதியது",
"Select Files": "கோப்புகளைத் தேர்வுசெய்யவும்",
"Select Image": "படத்தைத் தேர்வுசெய்யவும்",
"Select Video": "வீடியோவைத் தேர்வுசெய்யவும்",
"Select Audio": "ஆடியோவைத் தேர்வுசெய்யவும்",
"Select Fonts": "எழுத்துருக்களைத் தேர்வுசெய்யவும்",
"Storage": "சேமிப்பு",
"{{percentage}}% of Cloud Sync Space Used.": "Cloud Sync இடத்தில் {{percentage}}% பயன்படுத்தப்பட்டுள்ளது.",
"Translation Characters": "மொழிபெயர்ப்பு எழுத்துகள்",
"{{percentage}}% of Daily Translation Characters Used.": "தினசரி மொழிபெயர்ப்பு எழுத்துகளில் {{percentage}}% பயன்படுத்தப்பட்டுள்ளது.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "தினசரி மொழிபெயர்ப்பு ஒதுக்கீட்டை அடைந்துவிட்டது. AI மொழிபெயர்ப்புகளைத் தொடர்ந்து பயன்படுத்த உங்கள் திட்டத்தை மேம்படுத்தவும்.",
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
"Azure Translator": "Azure Translator",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Yandex Translate": "Yandex Translate",
"Gray": "சாம்பல்",
"Sepia": "செபியா",
"Grass": "புல்",
"Cherry": "செர்ரி",
"Sky": "வானம்",
"Solarized": "சூரியமயம்",
"Gruvbox": "Gruvbox",
"Nord": "Nord",
"Contrast": "மாறுபாடு",
"Sunset": "சூரிய அஸ்தமனம்",
"Reveal in Finder": "Finder இல் காட்டவும்",
"Reveal in File Explorer": "File Explorer இல் காட்டவும்",
"Reveal in Folder": "கோப்புறையில் காட்டவும்",
"Previous Paragraph": "முந்தைய பத்தி",
"Previous Sentence": "முந்தைய வாக்கியம்",
"Pause": "இடைநிறுத்தம்",
"Play": "இயக்கு",
"Next Sentence": "அடுத்த வாக்கியம்",
"Next Paragraph": "அடுத்த பத்தி",
"Separate Cover Page": "தனித்த அட்டை பக்கம்"
}
@@ -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",
@@ -475,12 +474,10 @@
"Always use latest": "ใช้เวอร์ชันล่าสุดเสมอ",
"Send changes only": "ส่งการเปลี่ยนแปลงเท่านั้น",
"Receive changes only": "รับการเปลี่ยนแปลงเท่านั้น",
"Disabled": "ปิดใช้งาน",
"Checksum Method": "วิธีการตรวจสอบความถูกต้อง",
"File Content (recommended)": "เนื้อหาไฟล์ (แนะนำ)",
"File Name": "ชื่อไฟล์",
"Device Name": "ชื่ออุปกรณ์",
"Sync Tolerance": "ความทนทานต่อการซิงค์",
"Connect to your KOReader Sync server.": "เชื่อมต่อกับเซิร์ฟเวอร์ KOReader Sync ของคุณ",
"Server URL": "URL ของเซิร์ฟเวอร์",
"Username": "ชื่อผู้ใช้",
@@ -499,6 +496,27 @@
"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": "ดึงความคืบหน้า",
"Previous Paragraph": "ย่อหน้าก่อนหน้า",
"Previous Sentence": "ประโยคก่อนหน้า",
"Pause": "หยุดชั่วคราว",
"Play": "เล่น",
"Next Sentence": "ประโยคถัดไป",
"Next Paragraph": "ย่อหน้าถัดไป",
"Separate Cover Page": "หน้าปกแยกต่างหาก"
}
@@ -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",
@@ -479,12 +478,10 @@
"Always use latest": "Her Zaman En Sonunu Kullan",
"Send changes only": "Sadece Değişiklikleri Gönder",
"Receive changes only": "Sadece Değişiklikleri Al",
"Disabled": "Devre Dışı",
"Checksum Method": "Kontrol Toplamı Yöntemi",
"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 +500,27 @@
"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",
"Previous Paragraph": "Önceki Paragraf",
"Previous Sentence": "Önceki Cümle",
"Pause": "Duraklat",
"Play": "Oynat",
"Next Sentence": "Sonraki Cümle",
"Next Paragraph": "Sonraki Paragraf",
"Separate Cover Page": "Ayrı Kapak Sayfası"
}
@@ -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 Перекладач (росія)",
@@ -487,12 +486,10 @@
"Always use latest": "Завжди використовувати найновішу",
"Send changes only": "Відправити тільки зміни",
"Receive changes only": "Отримати тільки зміни",
"Disabled": "Вимкнено",
"Checksum Method": "Метод контролю суми",
"File Content (recommended)": "Вміст файлу (рекомендується)",
"File Name": "Ім'я файлу",
"Device Name": "Ім'я пристрою",
"Sync Tolerance": "Допустима похибка синхронізації",
"Connect to your KOReader Sync server.": "Підключіться до свого сервера синхронізації KOReader.",
"Server URL": "URL сервера",
"Username": "Ім'я користувача",
@@ -511,6 +508,27 @@
"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": "Отримати прогрес",
"Previous Paragraph": "Попередній абзац",
"Previous Sentence": "Попереднє речення",
"Pause": "Пауза",
"Play": "Відтворити",
"Next Sentence": "Наступне речення",
"Next Paragraph": "Наступний абзац",
"Separate Cover Page": "Окрема сторінка обкладинки"
}
@@ -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",
@@ -475,12 +474,10 @@
"Always use latest": "Luôn sử dụng phiên bản mới nhất",
"Send changes only": "Chỉ gửi thay đổi",
"Receive changes only": "Chỉ nhận thay đổi",
"Disabled": "Đã tắt",
"Checksum Method": "Phương pháp kiểm tra",
"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 +496,27 @@
"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 độ",
"Previous Paragraph": "Đoạn trước",
"Previous Sentence": "Câu trước",
"Pause": "Tạm dừng",
"Play": "Phát",
"Next Sentence": "Câu tiếp theo",
"Next Paragraph": "Đoạn tiếp theo",
"Separate Cover Page": "Trang Bìa Riêng"
}
@@ -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 翻译",
@@ -475,12 +474,10 @@
"Always use latest": "始终使用最新",
"Send changes only": "仅发送更改",
"Receive changes only": "仅接收更改",
"Disabled": "已禁用",
"Checksum Method": "校验和方法",
"File Content (recommended)": "文件内容(推荐)",
"File Name": "文件名",
"Device Name": "设备名称",
"Sync Tolerance": "同步容忍度",
"Connect to your KOReader Sync server.": "连接到您的 KOReader 同步服务器。",
"Server URL": "服务器 URL",
"Username": "用户名",
@@ -499,6 +496,27 @@
"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": "拉取阅读进度",
"Previous Paragraph": "上一段",
"Previous Sentence": "上一句",
"Pause": "暂停",
"Play": "播放",
"Next Sentence": "下一句",
"Next Paragraph": "下一段",
"Separate Cover Page": "单独封面页"
}
@@ -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 翻譯",
@@ -475,12 +474,10 @@
"Always use latest": "始終使用最新",
"Send changes only": "僅發送更改",
"Receive changes only": "僅接收更改",
"Disabled": "已禁用",
"Checksum Method": "校驗和方法",
"File Content (recommended)": "文件內容(推薦)",
"File Name": "文件名",
"Device Name": "設備名稱",
"Sync Tolerance": "同步容忍度",
"Connect to your KOReader Sync server.": "連接到您的 KOReader 同步伺服器。",
"Server URL": "伺服器 URL",
"Username": "用戶名",
@@ -499,6 +496,27 @@
"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": "拉取閱讀進度",
"Previous Paragraph": "上一段",
"Previous Sentence": "上一句",
"Pause": "暫停",
"Play": "播放",
"Next Sentence": "下一句",
"Next Paragraph": "下一段",
"Separate Cover Page": "單獨封面頁"
}
+46
View File
@@ -1,5 +1,51 @@
{
"releases": {
"0.9.78": {
"date": "2025-09-06",
"notes": [
"TTS: Added option to keep a persistent TTS bar at the bottom of the screen",
"TTS: Added previous/next sentence navigation controls in the TTS bar",
"TTS: Added media control support on iOS lock screen and AirPods",
"TTS: Fixed an issue where headings could not be highlighted for TTS",
"Sync: Fixed occasional sync failures between KOReader and Readest",
"Fonts: Grouped font styles into custom font families for easier selection",
"Reader: Improved text selection sensitivity when using a stylus",
"Reader: Books can now be properly closed using the system back button on Android",
"EPUB: Fixed parsing to correctly detect the front cover in some EPUB files",
"PDF: Added zoom level, zoom mode, and spread mode settings for PDFs",
"Reader: Bookmarks can now be edited directly in the sidebar",
"Layout: Prevented layout shift when the virtual keyboard appears on Android",
"TXT: Improved chapter detection and added support for nested table of contents in TXT files"
]
},
"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": [
@@ -23,6 +23,8 @@ VERSION_CODE=$((10#$MAJOR * 10000 + 10#$MINOR * 1000 + 10#$PATCH))
echo "🔢 Computed versionCode: $VERSION_CODE"
PROPERTIES_FILE="./src-tauri/gen/android/app/tauri.properties"
MANIFEST="./src-tauri/gen/android/app/src/main/AndroidManifest.xml"
PERMISSION_LINE='<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>'
if [[ ! -f "$PROPERTIES_FILE" ]]; then
echo "❌ File not found: $PROPERTIES_FILE"
@@ -36,9 +38,31 @@ mv "$tmpfile" "$PROPERTIES_FILE"
echo "✅ Updated $PROPERTIES_FILE"
# --- REMOVE PERMISSION BEFORE BUILD ---
if grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST"; then
echo "🧹 Removing REQUEST_INSTALL_PACKAGES from AndroidManifest.xml"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "/REQUEST_INSTALL_PACKAGES/d" "$MANIFEST"
else
sed -i "/REQUEST_INSTALL_PACKAGES/d" "$MANIFEST"
fi
fi
echo "🚀 Running: pnpm tauri android build"
pnpm tauri android build
# --- ADD PERMISSION BACK AFTER BUILD ---
if ! grep -q 'REQUEST_INSTALL_PACKAGES' "$MANIFEST"; then
echo "♻️ Restoring REQUEST_INSTALL_PACKAGES in AndroidManifest.xml"
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "/android.permission.INTERNET/a\\
$PERMISSION_LINE
" "$MANIFEST"
else
sed -i "/android.permission.INTERNET/a \ $PERMISSION_LINE" "$MANIFEST"
fi
fi
source .env.google-play.local
if [[ -z "$GOOGLE_PLAY_JSON_KEY_FILE" ]]; then
echo "❌ GOOGLE_PLAY_JSON_KEY_FILE is not set"
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -30,27 +31,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 +88,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 +96,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"],
@@ -88,7 +88,7 @@ describe('CFIToXPointerConverter', () => {
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[0]',
xpointer: '/body/DocFragment[2]/body/div/p[1]',
});
});
@@ -99,7 +99,7 @@ describe('CFIToXPointerConverter', () => {
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[1]',
xpointer: '/body/DocFragment[2]/body/div/p[2]',
});
});
@@ -110,7 +110,7 @@ describe('CFIToXPointerConverter', () => {
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer).toEqual({
xpointer: '/body/DocFragment[2]/body/div/p[2]',
xpointer: '/body/DocFragment[2]/body/div/p[3]',
});
});
});
@@ -126,9 +126,9 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toEqual('/body/DocFragment[3]/body/div/p[0]/text().6');
expect(xpointer.pos0).toEqual('/body/DocFragment[3]/body/div/p[0]/text().6');
expect(xpointer.pos1).toEqual('/body/DocFragment[3]/body/div/p[1]/text().16');
expect(xpointer.xpointer).toEqual('/body/DocFragment[3]/body/div/p[1]/text().6');
expect(xpointer.pos0).toEqual('/body/DocFragment[3]/body/div/p[1]/text().6');
expect(xpointer.pos1).toEqual('/body/DocFragment[3]/body/div/p[2]/text().16');
});
it('should convert range CFI within same element', () => {
@@ -147,8 +147,8 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.pos0).toMatch(/p\[0\]/);
expect(xpointer.pos1).toMatch(/p\[2\]/);
expect(xpointer.pos0).toMatch(/p\[1\]/);
expect(xpointer.pos1).toMatch(/p\[3\]/);
});
});
@@ -163,7 +163,7 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[0\]/);
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]/);
});
it('should handle elements with IDs', () => {
@@ -172,7 +172,7 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[0\]/);
expect(xpointer.xpointer).toMatch(/\/body\/section\[2\]\/p\[1\]/);
});
it('should handle inline elements', () => {
@@ -181,7 +181,7 @@ describe('CFIToXPointerConverter', () => {
const convertedCfi = converter.xPointerToCFI(xpointer.xpointer);
expect(originalCfi).toEqual(convertedCfi);
expect(xpointer.xpointer).toMatch(/\/body\/section\[1\]\/p\[1\]/);
expect(xpointer.xpointer).toMatch(/\/body\/section\[2\]\/p\[2\]/);
});
});
@@ -212,7 +212,7 @@ describe('CFIToXPointerConverter', () => {
});
it('should convert XPointer to CFI for first element', () => {
const xpointer = '/body/DocFragment[2]/body/div/p[0]';
const xpointer = '/body/DocFragment[2]/body/div/p[1]';
const cfi = converter.xPointerToCFI(xpointer);
// Verify by converting back to XPointer
@@ -221,7 +221,7 @@ describe('CFIToXPointerConverter', () => {
});
it('should convert XPointer to CFI for second element', () => {
const xpointer = '/body/DocFragment[2]/body/div/p[1]';
const xpointer = '/body/DocFragment[2]/body/div/p[2]';
const cfi = converter.xPointerToCFI(xpointer);
const backToXPointer = converter.cfiToXPointer(cfi);
@@ -229,14 +229,14 @@ describe('CFIToXPointerConverter', () => {
});
it('should convert XPointer with text offset to CFI', () => {
const xpointer = '/body/DocFragment[2]/body/div[0]/p[0]/text().6';
const xpointer = '/body/DocFragment[2]/body/div[0]/p[1]/text().6';
const cfi = converter.xPointerToCFI(xpointer);
expect(cfi).toBe('epubcfi(/6/4!/4/2/2/1:6)');
});
it('should convert range XPointer to CFI', () => {
const pos0 = '/body/DocFragment[2]/body/div/p[0]/text().6';
const pos1 = '/body/DocFragment[2]/body/div/p[1]/text().16';
const pos0 = '/body/DocFragment[2]/body/div/p[1]/text().6';
const pos1 = '/body/DocFragment[2]/body/div/p[2]/text().16';
const cfi = converter.xPointerToCFI(pos0, pos1);
const xpointer = converter.cfiToXPointer(cfi);
@@ -269,7 +269,7 @@ describe('CFIToXPointerConverter', () => {
it('should handle CFI without spine step prefix', () => {
// Test the adjustSpineIndex method handles CFIs that don't start with /6/n!
const converter = new XCFI(simpleDoc, 3); // Use different spine index
const xpointer = '/body/DocFragment[4]/body/div/p[0]';
const xpointer = '/body/DocFragment[4]/body/div/p[1]';
const cfi = converter.xPointerToCFI(xpointer);
// Verify the spine step is correctly added/adjusted
@@ -331,7 +331,7 @@ describe('CFIToXPointerConverter', () => {
const cfi = 'epubcfi(/6/6!/4/2/2)'; // Empty p element
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[0]');
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[1]');
});
it('should handle whitespace-only text nodes', () => {
@@ -353,7 +353,7 @@ describe('CFIToXPointerConverter', () => {
const cfi = 'epubcfi(/6/6!/4/2/4)'; // Second p element
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[1]');
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/p[2]');
});
it('should handle deeply nested elements', () => {
@@ -379,7 +379,7 @@ describe('CFIToXPointerConverter', () => {
const cfi = 'epubcfi(/6/6!/4/2/2/2/2)'; // Deeply nested p
const result = converter.cfiToXPointer(cfi);
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/section/article/p[0]');
expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/section/article/p[1]');
});
});
});
+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);
@@ -123,6 +123,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
bookKey={bookKey}
bookDoc={bookDoc}
config={config}
gridInsets={gridInsets}
contentInsets={contentInsets}
/>
{viewSettings.vertical && viewSettings.scrolled && (
@@ -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,7 +45,8 @@ import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import ConfirmSyncDialog from './ConfirmSyncDialog';
import Spinner from '@/components/Spinner';
import KOSyncConflictResolver from './KOSyncResolver';
declare global {
interface Window {
@@ -55,14 +58,18 @@ const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
config: BookConfig;
gridInsets: Insets;
contentInsets: Insets;
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
}> = ({ bookKey, bookDoc, config, gridInsets, 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 { getViewState, getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewRef = useRef<FoliateView | null>(null);
@@ -70,6 +77,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 +88,7 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
const {
syncState,
conflictDetails,
resolveConflictWithLocal,
resolveConflictWithRemote,
} = useKOSync(bookKey);
const { syncState, conflictDetails, resolveWithLocal, resolveWithRemote } = useKOSync(bookKey);
useTextTranslation(bookKey, viewRef.current);
const progressRelocateHandler = (event: Event) => {
@@ -127,6 +131,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 +150,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 +234,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');
@@ -244,6 +256,12 @@ const FoliateViewer: React.FC<{
}
}
if (bookDoc.rendition?.layout === 'pre-paginated' && bookDoc.sections) {
bookDoc.rendition.spread = viewSettings.spreadMode;
const coverSide = bookDoc.dir === 'rtl' ? 'right' : 'left';
bookDoc.sections[0]!.pageSpread = viewSettings.keepCoverSpread ? '' : coverSide;
}
await view.open(bookDoc);
// make sure we can listen renderer events after opening book
viewRef.current = view;
@@ -279,9 +297,14 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
view.renderer.setAttribute('max-column-count', maxColumnCount);
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
if (bookDoc?.rendition?.layout === 'pre-paginated') {
view.renderer.setAttribute('zoom', viewSettings.zoomMode);
view.renderer.setAttribute('spread', viewSettings.spreadMode);
} else {
view.renderer.setAttribute('max-column-count', maxColumnCount);
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
}
applyMarginAndGap();
const lastLocation = config.location;
@@ -298,6 +321,7 @@ const FoliateViewer: React.FC<{
const applyMarginAndGap = () => {
const viewSettings = getViewSettings(bookKey)!;
const viewState = getViewState(bookKey);
const viewInsets = getViewInsets(viewSettings);
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
const showDoubleBorderHeader = showDoubleBorder && viewSettings.showHeader;
@@ -305,7 +329,11 @@ const FoliateViewer: React.FC<{
const showTopHeader = viewSettings.showHeader && !viewSettings.vertical;
const showBottomFooter = viewSettings.showFooter && !viewSettings.vertical;
const moreTopInset = showTopHeader ? Math.max(0, 44 - insets.top) : 0;
const moreBottomInset = showBottomFooter ? Math.max(0, 44 - insets.bottom) : 0;
const ttsBarHeight =
viewState?.ttsEnabled && viewSettings.showTTSBar ? 52 + gridInsets.bottom * 0.33 : 0;
const moreBottomInset = showBottomFooter
? Math.max(0, Math.max(ttsBarHeight, 44) - insets.bottom)
: Math.max(0, ttsBarHeight);
const moreRightInset = showDoubleBorderHeader ? 32 : 0;
const moreLeftInset = showDoubleBorderFooter ? 32 : 0;
const topMargin = (showTopHeader ? insets.top : viewInsets.top) + moreTopInset;
@@ -335,6 +363,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;
@@ -354,6 +397,8 @@ const FoliateViewer: React.FC<{
viewSettings?.doubleBorder,
viewSettings?.showHeader,
viewSettings?.showFooter,
viewSettings?.showTTSBar,
viewState?.ttsEnabled,
]);
return (
@@ -364,12 +409,13 @@ const FoliateViewer: React.FC<{
{...mouseHandlers}
{...touchHandlers}
/>
{!docLoaded.current && loading && <Spinner loading={true} />}
{syncState === 'conflict' && conflictDetails && (
<ConfirmSyncDialog
<KOSyncConflictResolver
details={conflictDetails}
onConfirmLocal={resolveConflictWithLocal}
onConfirmRemote={resolveConflictWithRemote}
onClose={resolveConflictWithLocal}
onResolveWithLocal={resolveWithLocal}
onResolveWithRemote={resolveWithRemote}
onClose={resolveWithLocal}
/>
)}
</>
@@ -183,11 +183,12 @@ const FooterBar: React.FC<FooterBarProps> = ({
/>
<div
className={clsx(
'footer-bar shadow-xs bottom-0 z-50 flex w-full flex-col',
'footer-bar shadow-xs bottom-0 z-30 flex w-full flex-col',
'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',
@@ -210,7 +211,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px',
bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px',
}}
>
<div className='flex w-full items-center justify-between gap-x-6'>
@@ -264,7 +265,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px',
bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px',
}}
>
<Slider
@@ -303,10 +304,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
</div>
<div
className={clsx(
'bg-base-200 z-50 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
'bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
)}
style={{
paddingBottom: isMobile ? `${gridInsets.bottom + 16}px` : '0px',
paddingBottom: isMobile ? `${gridInsets.bottom * 0.33 + 16}px` : '0px',
}}
>
<Button
@@ -336,7 +337,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
style={{
bottom: isMobile ? `${gridInsets.bottom / 2}px` : '0px',
bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px',
}}
>
<Button
@@ -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}
@@ -391,7 +392,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
/>
</div>
</div>
<TTSControl bookKey={bookKey} />
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
);
};
@@ -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,8 +136,18 @@ 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'>
<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}
</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);
@@ -3,17 +3,17 @@ import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { SyncDetails } from '../hooks/useKOSync';
interface ConfirmSyncDialogProps {
interface KOSyncConflictResolverProps {
details: SyncDetails | null;
onConfirmLocal: () => void;
onConfirmRemote: () => void;
onResolveWithLocal: () => void;
onResolveWithRemote: () => void;
onClose: () => void;
}
const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
const KOSyncConflictResolver: React.FC<KOSyncConflictResolverProps> = ({
details,
onConfirmLocal,
onConfirmRemote,
onResolveWithLocal,
onResolveWithRemote,
onClose,
}) => {
const _ = useTranslation();
@@ -28,7 +28,10 @@ const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
})}
</p>
<div className='mt-4 space-y-4'>
<button className='btn h-auto w-full flex-col items-start py-2' onClick={onConfirmLocal}>
<button
className='btn h-auto w-full flex-col items-start py-2'
onClick={onResolveWithLocal}
>
<span>{_('Local Progress')}</span>
<span className='text-xs font-normal normal-case text-gray-500'>
{details.local.preview}
@@ -36,7 +39,7 @@ const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
</button>
<button
className='btn btn-primary h-auto w-full flex-col items-start py-2'
onClick={onConfirmRemote}
onClick={onResolveWithRemote}
>
<span>{_('Remote Progress')}</span>
<span className='text-xs font-normal normal-case'>{details.remote.preview}</span>
@@ -46,4 +49,4 @@ const ConfirmSyncDialog: React.FC<ConfirmSyncDialogProps> = ({
);
};
export default ConfirmSyncDialog;
export default KOSyncConflictResolver;
@@ -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}
@@ -62,7 +62,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
className={clsx(
'progressinfo absolute bottom-0 flex items-center justify-between',
'text-neutral-content font-sans text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'h-[52px] w-full',
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
style={
@@ -78,16 +78,23 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
: {
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.67}px` : 0,
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
}
}
>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
<div
className={clsx(
'flex items-center justify-center',
isVertical ? 'h-full' : 'h-[52px] w-full',
)}
>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
</div>
</div>
);
};
@@ -3,6 +3,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useEffect, Suspense, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
@@ -16,23 +17,46 @@ 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';
import ReaderContent from './ReaderContent';
/*
Z-Index Layering Guide:
---------------------------------
99 Window Border (Linux only)
• Ensures the border stays on top of all UI elements.
50 Loading Progress / Toast Notifications / Dialogs
• Includes Settings, About, Updater, and KOSync dialogs.
45 Sidebar / Notebook (Unpinned)
• Floats above the content but below global dialogs.
40 TTS Bar
• Mini controls for TTS playback on top of the TTS Control.
30 Footbar (TTS Control)
• Persistent bottom controls and the TTS icon/panel.
20 Menu / Sidebar / Notebook (Pinned)
• Docked navigation or note views.
10 Headerbar / Ribbon
• Top toolbar and ribbon elements.
0 Base Content
• Main reading area or background content.
*/
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { setLibrary } = useLibraryStore();
const { hoveredBookKey } = useReaderStore();
const { settings, setSettings } = useSettingsStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, systemUIAlwaysHidden, showSystemUI, dismissSystemUI } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -52,8 +76,14 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const handleKeyDown = (event: CustomEvent) => {
if (event.detail.keyName === 'Back') {
setSideBarVisible(false);
setNotebookVisible(false);
if (getIsSideBarVisible()) {
setSideBarVisible(false);
} else if (getIsNotebookVisible()) {
setNotebookVisible(false);
} else {
eventDispatcher.dispatch('close-reader');
router.back();
}
return true;
}
return false;
@@ -61,22 +91,23 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useEffect(() => {
if (!appService?.isAndroidApp) return;
if (isSideBarVisible || isNotebookVisible) {
acquireBackKeyInterception();
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
if (!isSideBarVisible && !isNotebookVisible) {
acquireBackKeyInterception();
return () => {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.isAndroidApp]);
useEffect(() => {
if (!appService?.isAndroidApp) return;
eventDispatcher.onSync('native-key-down', handleKeyDown);
return () => {
if (appService?.isAndroidApp) {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible, isNotebookVisible]);
}, [appService?.isAndroidApp, isSideBarVisible, isNotebookVisible]);
useEffect(() => {
if (isInitiating.current) return;
@@ -106,24 +137,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>
);
};
@@ -67,10 +67,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (index === 0) setSideBarBookKey(key);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const handleShowBookDetails = (event: CustomEvent) => {
const book = event.detail as Book;
setShowDetailsBook(book);
setShowDetailsBook(event.detail as Book);
return true;
};
eventDispatcher.onSync('show-book-details', handleShowBookDetails);
@@ -78,7 +80,6 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
return () => {
eventDispatcher.offSync('show-book-details', handleShowBookDetails);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -94,10 +95,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
window.addEventListener('beforeunload', handleCloseBooks);
eventDispatcher.on('beforereload', handleCloseBooks);
eventDispatcher.on('close-reader', handleCloseBooks);
eventDispatcher.on('quit-app', handleCloseBooks);
return () => {
window.removeEventListener('beforeunload', handleCloseBooks);
eventDispatcher.off('beforereload', handleCloseBooks);
eventDispatcher.off('close-reader', handleCloseBooks);
eventDispatcher.off('quit-app', handleCloseBooks);
unlistenOnCloseWindow?.then((fn) => fn());
};
@@ -111,7 +114,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 +145,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 +182,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%'
@@ -6,6 +6,9 @@ import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { MdSync, MdSyncProblem } from 'react-icons/md';
import { IoMdExpand } from 'react-icons/io';
import { TbArrowAutofitWidth } from 'react-icons/tb';
import { TbColumns1, TbColumns2 } from 'react-icons/tb';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
@@ -37,14 +40,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const router = useRouter();
const { user } = useAuth();
const { envConfig, appService } = useEnv();
const { getConfig } = useBookDataStore();
const { getConfig, getBookData } = useBookDataStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const [zoomMode, setZoomMode] = useState(viewSettings!.zoomMode!);
const [spreadMode, setSpreadMode] = useState(viewSettings!.spreadMode!);
const [keepCoverSpread, setKeepCoverSpread] = useState(viewSettings!.keepCoverSpread!);
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
viewSettings!.invertImgColorInDark,
);
@@ -93,6 +100,9 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'zoomLevel', zoomLevel, true, true);
if (bookData.bookDoc?.rendition?.layout === 'pre-paginated') {
getView(bookKey)?.renderer.setAttribute('scale-factor', zoomLevel);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
@@ -102,6 +112,36 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
useEffect(() => {
if (zoomMode === viewSettings.zoomMode) return;
viewSettings.zoomMode = zoomMode;
getView(bookKey)?.renderer.setAttribute('zoom', zoomMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'zoomMode', zoomMode, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomMode]);
useEffect(() => {
if (spreadMode === viewSettings.spreadMode) return;
viewSettings.spreadMode = spreadMode;
getView(bookKey)?.renderer.setAttribute('spread', spreadMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'spreadMode', spreadMode, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spreadMode]);
useEffect(() => {
if (keepCoverSpread === viewSettings.keepCoverSpread) return;
if (!bookData?.bookDoc?.sections?.length) return;
viewSettings.keepCoverSpread = keepCoverSpread;
const coverSide = bookData.bookDoc.dir === 'rtl' ? 'right' : 'left';
bookData.bookDoc.sections[0]!.pageSpread = keepCoverSpread ? '' : coverSide;
getView(bookKey)?.renderer.setAttribute('spread', spreadMode);
setViewSettings(bookKey, viewSettings);
saveViewSettings(envConfig, bookKey, 'keepCoverSpread', keepCoverSpread, true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keepCoverSpread]);
const lastSyncTime = Math.max(config?.lastSyncedAtConfig || 0, config?.lastSyncedAtNotes || 0);
return (
@@ -145,6 +185,56 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
</button>
</div>
{bookData.bookDoc?.rendition?.layout === 'pre-paginated' && (
<>
<div className={clsx('my-2 flex items-center justify-between rounded-md')}>
<button
onClick={setSpreadMode.bind(null, 'none')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'none' && 'bg-base-300/75',
)}
>
<TbColumns1 />
</button>
<button
onClick={setSpreadMode.bind(null, 'auto')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
spreadMode === 'auto' && 'bg-base-300/75',
)}
>
<TbColumns2 />
</button>
<div className='bg-base-300 mx-2 h-6 w-[1px]' />
<button
onClick={setZoomMode.bind(null, 'fit-page')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-page' && 'bg-base-300/75',
)}
>
<IoMdExpand />
</button>
<button
onClick={setZoomMode.bind(null, 'fit-width')}
className={clsx(
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomMode === 'fit-width' && 'bg-base-300/75',
)}
>
<TbArrowAutofitWidth />
</button>
</div>
<MenuItem
label={_('Separate Cover Page')}
Icon={keepCoverSpread ? MdCheck : undefined}
onClick={() => setKeepCoverSpread(!keepCoverSpread)}
disabled={spreadMode === 'none'}
/>
</>
)}
<hr className='border-base-300 my-1' />
<MenuItem label={_('Font & Layout')} shortcut='Shift+F' onClick={openFontLayoutMenu} />
@@ -154,6 +244,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
shortcut='Shift+J'
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
disabled={bookData.bookDoc?.rendition?.layout === 'pre-paginated'}
/>
<hr className='border-base-300 my-1' />
@@ -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 ? (
@@ -1,5 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -7,6 +6,8 @@ import { TextSelection } from '@/utils/sel';
import { md5Fingerprint } from '@/utils/md5';
import { BookNote } from '@/types/book';
import useShortcuts from '@/hooks/useShortcuts';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
import TextButton from '@/components/TextButton';
interface NoteEditorProps {
onSave: (selection: TextSelection, note: string) => void;
@@ -23,110 +24,96 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
saveNotebookAnnotationDraft,
getNotebookAnnotationDraft,
} = useNotebookStore();
const editorRef = useRef<HTMLTextAreaElement>(null);
const [note, setNote] = React.useState('');
const editorRef = useRef<TextEditorRef>(null);
const [note, setNote] = useState('');
const separatorWidth = useResponsiveSize(3);
useEffect(() => {
if (editorRef.current) {
editorRef.current.focus();
}
}, [editorRef]);
useEffect(() => {
if (notebookEditAnnotation) {
setNote(notebookEditAnnotation.note);
if (editorRef.current) {
editorRef.current.value = notebookEditAnnotation.note;
editorRef.current.focus();
adjustHeight();
}
const noteText = notebookEditAnnotation.note;
setNote(noteText);
editorRef.current?.setValue(noteText);
editorRef.current?.focus();
} else if (notebookNewAnnotation) {
const noteText = getAnnotationText();
if (noteText) {
const draftNote = getNotebookAnnotationDraft(md5Fingerprint(noteText)) || '';
setNote(draftNote);
if (editorRef.current) {
editorRef.current.value = draftNote;
editorRef.current.focus();
adjustHeight();
}
editorRef.current?.setValue(draftNote);
editorRef.current?.focus();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notebookNewAnnotation, notebookEditAnnotation]);
const adjustHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = 'auto';
editorRef.current.style.height = `${editorRef.current.scrollHeight}px`;
}
};
const getAnnotationText = () => {
return notebookEditAnnotation?.text || notebookNewAnnotation?.text || '';
};
const handleOnChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight();
setNote(e.currentTarget.value);
const handleNoteChange = (value: string) => {
setNote(value);
};
const handleOnBlur = () => {
if (editorRef.current && editorRef.current.value) {
const handleBlur = () => {
const currentValue = editorRef.current?.getValue();
if (currentValue) {
const noteText = getAnnotationText();
if (noteText) {
saveNotebookAnnotationDraft(md5Fingerprint(noteText), editorRef.current.value);
saveNotebookAnnotationDraft(md5Fingerprint(noteText), currentValue);
}
}
};
const handleSaveNote = () => {
if (editorRef.current && notebookNewAnnotation) {
onSave(notebookNewAnnotation, editorRef.current.value);
} else if (editorRef.current && notebookEditAnnotation) {
notebookEditAnnotation.note = editorRef.current.value;
onEdit(notebookEditAnnotation);
const currentValue = editorRef.current?.getValue();
if (currentValue) {
if (notebookNewAnnotation) {
onSave(notebookNewAnnotation, currentValue);
} else if (notebookEditAnnotation) {
notebookEditAnnotation.note = currentValue;
onEdit(notebookEditAnnotation);
}
}
};
const handleEscape = () => {
if (notebookNewAnnotation) {
setNotebookNewAnnotation(null);
}
if (notebookEditAnnotation) {
setNotebookEditAnnotation(null);
}
};
useShortcuts({
onSaveNote: () => {
if (editorRef.current && editorRef.current.value) {
const currentValue = editorRef.current?.getValue();
if (currentValue) {
handleSaveNote();
}
},
onEscape: () => {
if (notebookNewAnnotation) {
setNotebookNewAnnotation(null);
}
if (notebookEditAnnotation) {
setNotebookEditAnnotation(null);
}
},
onEscape: handleEscape,
});
const canSave = Boolean(note.trim());
return (
<div className='content note-editor-container bg-base-100 mt-2 rounded-md p-2'>
<div className='flex w-full justify-between space-x-2'>
<div className='relative w-full'>
<textarea
className={clsx(
'note-editor textarea textarea-ghost min-h-[1em] resize-none !outline-none',
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
'content font-size-sm',
)}
dir='auto'
ref={editorRef}
value={note}
rows={1}
spellCheck={false}
onChange={handleOnChange}
onBlur={handleOnBlur}
placeholder={_('Add your notes here...')}
></textarea>
</div>
<div className='flex w-full'>
<TextEditor
ref={editorRef}
value={note}
onChange={handleNoteChange}
onBlur={handleBlur}
onSave={handleSaveNote}
onEscape={handleEscape}
placeholder={_('Add your notes here...')}
autoFocus={true}
spellCheck={false}
/>
</div>
<div className='flex items-center pt-2'>
<div
className='me-2 mt-0.5 min-h-full self-stretch rounded-xl bg-gray-300'
@@ -138,17 +125,12 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
<span className='content font-size-xs text-gray-500'>{getAnnotationText()}</span>
</div>
</div>
<div className='flex justify-end p-2' dir='ltr'>
<button
className={clsx(
'content btn btn-ghost font-size-sm hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={handleSaveNote}
>
<div className='font-size-sm pr-1 align-bottom text-blue-500'>{_('Save')}</div>
</button>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
<TextButton onClick={handleEscape}>{_('Cancel')}</TextButton>
<TextButton onClick={handleSaveNote} disabled={!canSave}>
{_('Save')}
</TextButton>
</div>
</div>
);
@@ -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();
@@ -189,22 +189,22 @@ const Notebook: React.FC = ({}) => {
return isNotebookVisible ? (
<>
{!isNotebookPinned && (
<div className='overlay fixed inset-0 z-10 bg-black/20' onClick={handleClickOverlay} />
<div className='overlay fixed inset-0 z-[45] bg-black/20' onClick={handleClickOverlay} />
)}
<div
className={clsx(
'notebook-container bg-base-200 right-0 z-20 flex min-w-60 select-none flex-col',
'notebook-container bg-base-200 right-0 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',
isNotebookPinned ? 'z-20' : 'z-[45] shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
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,233 @@
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 { CustomFont, mountCustomFont } from '@/styles/fonts';
import { parseFontInfo } from '@/utils/font';
import { getFilename } from '@/utils/path';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
interface CustomFontsProps {
bookKey: string;
onBack: () => void;
}
type FontFamily = {
name: string;
fonts: CustomFont[];
};
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 fontInfo = parseFontInfo(await fontFile.arrayBuffer(), fontPath);
const customFont = addFont(fontPath, {
name: fontInfo.name,
family: fontInfo.family,
style: fontInfo.style,
weight: fontInfo.weight,
});
console.log('Added custom font:', customFont);
if (customFont && !customFont.error) {
const loadedFont = await loadFont(envConfig, customFont.id);
mountCustomFont(document, loadedFont);
}
}
saveCustomFonts(envConfig);
});
};
const handleDeleteFamily = (family: FontFamily) => {
for (const font of family.fonts) {
if (font) {
if (removeFont(font.id)) {
appService!.fs.removeFile(font.path, 'Fonts');
saveCustomFonts(envConfig);
if (getAvailableFonts().length === 0) {
setIsDeleteMode(false);
}
}
}
}
};
const handleSelectFamily = (family: FontFamily) => {
if (currentDefaultFont === 'serif') {
saveViewSettings(envConfig, bookKey, 'serifFont', family.name);
} else {
saveViewSettings(envConfig, bookKey, 'sansSerifFont', family.name);
}
};
const toggleDeleteMode = () => {
setIsDeleteMode(!isDeleteMode);
};
const getAvailableFamilies = (fonts: CustomFont[]): FontFamily[] => {
const familyMap = new Map<string, string[]>();
for (const font of fonts) {
const family = font.family || font.name;
if (!familyMap.has(family)) {
familyMap.set(family, []);
}
familyMap.get(family)!.push(font.id);
}
return Array.from(familyMap.entries()).map(([family, ids]) => ({
name: family,
fonts: ids.map((id) => fonts.find((f) => f.id === id)!).filter((f): f is CustomFont => !!f),
}));
};
const availableFonts = customFonts
.filter((font) => !font.deletedAt)
.sort((a, b) => (b.downloadedAt || 0) - (a.downloadedAt || 0));
const availableFamilies = getAvailableFamilies(availableFonts);
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>
{availableFamilies.map((family) => (
<div
key={family.name}
className={clsx(
'card h-12 border shadow-sm',
currentFontFamily === family.name
? 'border-primary/50 bg-primary/50'
: `border-base-200 bg-base-200 ${isDeleteMode ? '' : 'cursor-pointer'}`,
)}
onClick={!isDeleteMode ? () => handleSelectFamily(family) : undefined}
title={family.fonts.map((f) => f.name).join('\n')}
>
<div className='card-body flex items-center justify-center p-2'>
<div
style={{
fontFamily: `"${family.name}", sans-serif`,
fontWeight: 400,
}}
className='text-base-content line-clamp-1 break-all'
>
{family.name}
</div>
{isDeleteMode && (
<button
onClick={() => handleDeleteFamily(family)}
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 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;
@@ -68,7 +74,7 @@ const FontFace = ({
const _ = useTranslation();
return (
<div className={clsx('config-item', className)}>
<span className='min-w-10'>{label}</span>
<span className='line-clamp-2 min-w-10'>{label}</span>
<FontDropdown
family={family}
options={options.map((option) => ({ option, label: _(option) }))}
@@ -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')}
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import React from 'react';
import React, { useRef, useState } from 'react';
import { marked } from 'marked';
import { useEnv } from '@/context/EnvContext';
@@ -13,6 +13,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import useScrollToItem from '../../hooks/useScrollToItem';
import TextButton from '@/components/TextButton';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
interface BooknoteItemProps {
bookKey: string;
@@ -26,9 +28,13 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById } = useReaderStore();
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
const separatorWidth = useResponsiveSize(3);
const { text, cfi, note } = item;
const editorRef = useRef<TextEditorRef>(null);
const editorDraftRef = useRef<string>(text || '');
const [inlineEditMode, setInlineEditMode] = useState(false);
const separatorWidth = useResponsiveSize(3);
const progress = getProgress(bookKey);
const { isCurrent, viewRef } = useScrollToItem(cfi, progress);
@@ -65,6 +71,57 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
setNotebookEditAnnotation(note);
};
const editBookmark = () => {
setInlineEditMode(true);
};
const handleSaveBookmark = () => {
setInlineEditMode(false);
const config = getConfig(bookKey);
if (!config || !editorDraftRef.current) return;
const { booknotes: annotations = [] } = config;
const existingIndex = annotations.findIndex((annotation) => item.id === annotation.id);
if (existingIndex === -1) return;
annotations[existingIndex]!.updatedAt = Date.now();
annotations[existingIndex]!.text = editorDraftRef.current;
const updatedConfig = updateBooknotes(bookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
};
if (inlineEditMode) {
return (
<div
className={clsx(
'border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/55 bg-base-100',
'transition-all duration-300 ease-in-out',
)}
>
<div className='flex w-full'>
<TextEditor
className='!leading-normal'
ref={editorRef}
value={editorDraftRef.current}
onChange={(value) => (editorDraftRef.current = value)}
onSave={handleSaveBookmark}
onEscape={() => setInlineEditMode(false)}
autoFocus={true}
spellCheck={false}
/>
</div>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
<TextButton onClick={() => setInlineEditMode(false)}>{_('Cancel')}</TextButton>
<TextButton onClick={handleSaveBookmark} disabled={!editorDraftRef.current}>
{_('Save')}
</TextButton>
</div>
</div>
);
}
return (
<li
ref={viewRef}
@@ -120,7 +177,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
</div>
<div
className={clsx(
'max-h-0 overflow-hidden p-0 text-xs',
'max-h-0 overflow-hidden p-0',
'transition-[max-height] duration-300 ease-in-out',
'group-hover:max-h-8 group-hover:overflow-visible',
)}
@@ -138,46 +195,23 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
</span>
</div>
<div className='flex items-center justify-end space-x-3 p-2' dir='ltr'>
{item.note && (
<div
className={clsx(
'content settings-content cursor-pointer',
'flex items-end p-0 hover:bg-transparent',
)}
onClick={editNote.bind(null, item)}
{(item.note || item.type === 'bookmark') && (
<TextButton
onClick={item.type === 'bookmark' ? editBookmark : editNote.bind(null, item)}
variant='primary'
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
>
<div
className={clsx(
'align-bottom text-blue-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-blue-600',
)}
>
{_('Edit')}
</div>
</div>
{_('Edit')}
</TextButton>
)}
<div
className={clsx(
'content settings-content cursor-pointer',
'flex items-end p-0 hover:bg-transparent',
)}
<TextButton
onClick={deleteNote.bind(null, item)}
variant='danger'
className='opacity-0 transition duration-300 ease-in-out group-hover:opacity-100'
>
<div
className={clsx(
'align-bottom text-red-500',
'transition duration-300 ease-in-out',
'content font-size-sm',
'opacity-0 group-hover:opacity-100',
'hover:text-red-600',
)}
>
{_('Delete')}
</div>
</div>
{_('Delete')}
</TextButton>
</div>
</div>
</div>
@@ -2,7 +2,9 @@ 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 { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
@@ -15,12 +17,15 @@ const SidebarContent: React.FC<{
bookDoc: BookDoc;
sideBarBookKey: string;
}> = ({ bookDoc, sideBarBookKey }) => {
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { setHoveredBookKey } = useReaderStore();
const { setSideBarVisible } = useSidebarStore();
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
const [fade, setFade] = useState(false);
const [targetTab, setTargetTab] = useState(activeTab);
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
useEffect(() => {
if (!sideBarBookKey) return;
@@ -32,8 +37,13 @@ const SidebarContent: React.FC<{
const handleTabChange = (tab: string) => {
setFade(true);
const timeout = setTimeout(() => {
setFade(false);
if (activeTab === tab && isMobile) {
setHoveredBookKey(sideBarBookKey);
setSideBarVisible(false);
return;
}
setTargetTab(tab);
setFade(false);
setConfig(sideBarBookKey!, config);
clearTimeout(timeout);
}, 300);
@@ -75,10 +85,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();
@@ -181,20 +181,26 @@ const SideBar: React.FC<{
return isSideBarVisible ? (
<>
{!isSideBarPinned && (
<div
className='overlay fixed inset-0 z-[45] bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
)}
<div
className={clsx(
'sidebar-container bg-base-200 z-20 flex min-w-60 select-none flex-col',
'sidebar-container bg-base-200 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',
isSideBarPinned ? 'z-20' : 'z-[45] shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${sideBarWidth}`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
position: isSideBarPinned ? 'relative' : 'absolute',
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<style jsx>{`
@@ -266,12 +272,6 @@ const SideBar: React.FC<{
onTouchStart={handleHorizontalDragStart}
></div>
</div>
{!isSideBarPinned && (
<div
className='overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
)}
</>
) : null;
};
@@ -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
@@ -26,7 +26,7 @@ const TabNavigation: React.FC<{
>
<div
className={clsx(
'bg-base-300 absolute bottom-1.5 start-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'bg-base-300 absolute bottom-1.5 start-1 z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'transform transition-transform duration-300',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
@@ -36,7 +36,7 @@ const TabNavigation: React.FC<{
{tabs.map((tab) => (
<div
key={tab}
className='lg:tooltip lg:tooltip-top z-50 m-1.5 flex-1 cursor-pointer rounded-md p-2'
className='lg:tooltip lg:tooltip-top z-20 m-1.5 flex-1 cursor-pointer rounded-md p-2'
data-tip={
tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')
}
@@ -0,0 +1,100 @@
import clsx from 'clsx';
import React from 'react';
import {
MdPlayArrow,
MdOutlinePause,
MdFastRewind,
MdFastForward,
MdSkipPrevious,
MdSkipNext,
} from 'react-icons/md';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTranslation } from '@/hooks/useTranslation';
type TTSBarProps = {
bookKey: string;
isPlaying: boolean;
onTogglePlay: () => void;
onBackward: (byMark: boolean) => void;
onForward: (byMark: boolean) => void;
gridInsets: Insets;
};
const TTSBar = ({
bookKey,
isPlaying,
onTogglePlay,
onBackward,
onForward,
gridInsets,
}: TTSBarProps) => {
const _ = useTranslation();
const { appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const iconSize32 = useResponsiveSize(30);
const iconSize48 = useResponsiveSize(36);
const isVisible = hoveredBookKey !== bookKey;
return (
<div
className={clsx(
'bg-base-100 absolute bottom-0 z-40',
'inset-x-0 mx-auto flex w-full justify-center sm:w-fit',
'transition-opacity duration-300',
isVisible ? `pointer-events-auto opacity-100` : `pointer-events-none opacity-0`,
)}
style={{ paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0 }}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey('')}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey('')}
>
<div className='text-base-content flex h-[52px] items-center space-x-2 px-2'>
<button
onClick={onBackward.bind(null, false)}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Previous Paragraph')}
aria-label={_('Previous Paragraph')}
>
<MdFastRewind size={iconSize32} />
</button>
<button
onClick={onBackward.bind(null, true)}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Previous Sentence')}
aria-label={_('Previous Sentence')}
>
<MdSkipPrevious size={iconSize32} />
</button>
<button
onClick={onTogglePlay}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={isPlaying ? _('Pause') : _('Play')}
aria-label={isPlaying ? _('Pause') : _('Play')}
>
{isPlaying ? <MdOutlinePause size={iconSize48} /> : <MdPlayArrow size={iconSize48} />}
</button>
<button
onClick={onForward.bind(null, true)}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Next Sentence')}
aria-label={_('Next Sentence')}
>
<MdSkipNext size={iconSize32} />
</button>
<button
onClick={onForward.bind(null, false)}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Next Paragraph')}
aria-label={_('Next Paragraph')}
>
<MdFastForward size={iconSize32} />
</button>
</div>
</div>
);
};
export default TTSBar;
@@ -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';
@@ -12,9 +13,11 @@ import { parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { CFI } from '@/libs/document';
import { Insets } from '@/types/misc';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
import TTSBar from './TTSBar';
const POPUP_WIDTH = 282;
const POPUP_HEIGHT = 160;
@@ -22,19 +25,23 @@ const POPUP_PADDING = 10;
interface TTSControlProps {
bookKey: string;
gridInsets: Insets;
}
const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { getBookData } = useBookDataStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showIndicator, setShowIndicator] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [showTTSBar, setShowTTSBar] = useState(() => !!viewSettings?.showTTSBar);
const [panelPosition, setPanelPosition] = useState<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
@@ -42,7 +49,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
const viewSettings = getViewSettings(bookKey);
const popupPadding = useResponsiveSize(POPUP_PADDING);
const maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
@@ -62,11 +68,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
unblockerAudioRef.current.addEventListener('play', () => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = null;
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.setActionHandler('stop', null);
navigator.mediaSession.setActionHandler('seekbackward', null);
navigator.mediaSession.setActionHandler('seekforward', null);
}
});
unblockerAudioRef.current.preload = 'auto';
@@ -121,7 +122,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
const mark = (e as CustomEvent<TTSMark>).detail;
if (appService?.isMobileApp && 'mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: mark.text,
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
@@ -238,7 +239,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
}
};
const handleTogglePlay = async () => {
const handleTogglePlay = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
@@ -257,41 +258,54 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
await ttsController.start();
}
}
};
}, [isPlaying, isPaused]);
const handleBackward = async () => {
const handleBackward = useCallback(async (byMark = false) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward();
await ttsController.backward(byMark);
}
};
}, []);
const handleForward = async () => {
const handleForward = useCallback(async (byMark = false) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward();
await ttsController.forward(byMark);
}
};
}, []);
const handleStop = async (bookKey: string) => {
const handlePause = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
setIsPaused(true);
await ttsController.pause();
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (appService?.isMobile) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
};
}, []);
const handleStop = useCallback(
async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (appService?.isMobile) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
// rate range: 0.5 - 3, 1.0 is normal speed
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -361,6 +375,16 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
}
};
const handleToggleTTSBar = () => {
const viewSettings = getViewSettings(bookKey)!;
viewSettings.showTTSBar = !viewSettings.showTTSBar;
setShowTTSBar(viewSettings.showTTSBar);
if (viewSettings.showTTSBar) {
setShowPanel(false);
}
setViewSettings(bookKey, viewSettings);
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
@@ -399,6 +423,30 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
setShowPanel(false);
};
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('pause', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('stop', () => {
handlePause();
});
navigator.mediaSession.setActionHandler('seekforward', () => {
handleForward();
});
navigator.mediaSession.setActionHandler('seekbackward', () => {
handleBackward();
});
}
}, [handleTogglePlay, handlePause, handleForward, handleBackward]);
useEffect(() => {
if (!iconRef.current || !showPanel) return;
const parentElement = iconRef.current.parentElement;
@@ -428,11 +476,15 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
ref={iconRef}
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',
'transition-transform duration-300',
viewSettings?.rtl ? 'left-8' : 'right-6',
!appService?.hasSafeAreaInset && 'bottom-[70px] sm:bottom-14',
)}
style={{
bottom: appService?.hasSafeAreaInset
? `${(safeAreaInsets?.bottom || 0) * 0.33 + (hoveredBookKey ? 70 : 52)}px`
: undefined,
}}
>
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
</div>
@@ -459,9 +511,20 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
onSelectTimeout={handleSelectTimeout}
onToogleTTSBar={handleToggleTTSBar}
/>
</Popup>
)}
{showIndicator && showTTSBar && ttsClientsInited && (
<TTSBar
bookKey={bookKey}
isPlaying={isPlaying}
onBackward={handleBackward}
onTogglePlay={handleTogglePlay}
onForward={handleForward}
gridInsets={gridInsets}
/>
)}
</>
);
};
@@ -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',
}}
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md';
import { TbChevronCompactDown, TbChevronCompactUp } from 'react-icons/tb';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoicesGroup } from '@/services/tts';
@@ -25,6 +26,7 @@ type TTSPanelProps = {
onSetVoice: (voice: string, lang: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (bookKey: string, value: number) => void;
onToogleTTSBar: () => void;
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
@@ -113,6 +115,7 @@ const TTSPanel = ({
onSetVoice,
onGetVoiceId,
onSelectTimeout,
onToogleTTSBar,
}: TTSPanelProps) => {
const _ = useTranslation();
const { envConfig } = useEnv();
@@ -200,7 +203,7 @@ const TTSPanel = ({
const timeoutOptions = getTTSTimeoutOptions(_);
return (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl px-4 pt-4 sm:gap-1'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
className='range'
@@ -231,23 +234,39 @@ const TTSPanel = ({
</div>
</div>
<div className='flex items-center justify-between space-x-2'>
<button onClick={onBackward} className='rounded-full p-1'>
<button
onClick={onBackward}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Previous Paragraph')}
aria-label={_('Previous Paragraph')}
>
<MdFastRewind size={iconSize32} />
</button>
<button onClick={onTogglePlay} className='rounded-full p-1'>
<button
onClick={onTogglePlay}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={isPlaying ? _('Pause') : _('Play')}
aria-label={isPlaying ? _('Pause') : _('Play')}
>
{isPlaying ? (
<MdPauseCircle size={iconSize48} className='fill-primary' />
) : (
<MdPlayCircle size={iconSize48} className='fill-primary' />
)}
</button>
<button onClick={onForward} className='rounded-full p-1'>
<button
onClick={onForward}
className='rounded-full p-1 transition-transform duration-200 hover:scale-105'
title={_('Next Paragraph')}
aria-label={_('Next Paragraph')}
>
<MdFastForward size={iconSize32} />
</button>
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1'
className='flex flex-col items-center justify-center rounded-full p-1 transition-transform duration-200 hover:scale-105'
onClick={(e) => e.currentTarget.focus()}
>
<MdAlarm size={iconSize32} />
{timeoutCountdown && (
@@ -290,7 +309,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 transition-transform duration-200 hover:scale-105'
onClick={(e) => e.currentTarget.focus()}
>
<RiVoiceAiFill size={iconSize32} />
</button>
<ul
@@ -345,6 +368,15 @@ const TTSPanel = ({
</ul>
</div>
</div>
<div className='flex h-4 items-center justify-center opacity-60 transition-transform duration-200 hover:scale-105 hover:opacity-100'>
<button onClick={onToogleTTSBar} className='p-0'>
{viewSettings?.showTTSBar ? (
<TbChevronCompactUp size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
) : (
<TbChevronCompactDown size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
)}
</button>
</div>
</div>
);
};
+209 -375
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,441 +28,276 @@ 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 koProgress = normalizeProgressXPointer(remote.progress);
const cfi = await getCFIFromXPointer(koProgress, 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();
pushProgress.flush();
};
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]);
}, [progress, syncState, settings.kosync, pushProgress]);
useEffect(() => {
return () => {
pushProgress.flush();
};
}, [pushProgress]);
const resolveConflictWithLocal = () => {
const resolveWithLocal = () => {
pushProgress();
pushProgress.flush();
setSyncState('synced');
setConflictDetails(null);
};
const resolveConflictWithRemote = async () => {
const resolveWithRemote = 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 +306,9 @@ export const useKOSync = (bookKey: string) => {
syncState,
conflictDetails,
errorMessage,
resolveConflictWithLocal,
resolveConflictWithRemote,
pushProgress,
pullProgress,
resolveWithLocal,
resolveWithRemote,
};
};
@@ -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);
@@ -116,8 +116,9 @@ export const useProgressSync = (bookKey: string) => {
const view = getView(bookKey);
if (xPointer && view && bookData && bookData.bookDoc) {
const content = view.renderer.getContents()[0];
const koProgress = normalizeProgressXPointer(xPointer);
const candidateCFI = await getCFIFromXPointer(
xPointer,
koProgress,
content?.doc,
content?.index,
bookData.bookDoc,
+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 } : {}),
}}

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