Compare commits

..

119 Commits

Author SHA1 Message Date
Huang Xin 4fde9955b2 release: version 0.9.61 (#1479) 2025-06-26 11:27:02 +02:00
Huang Xin 3875c8f239 feat: add an option to show remaining pages in chapter (#1478) 2025-06-26 11:19:30 +02:00
Huang Xin 7bd1b03652 fix: get correct filename when importing txt files on iOS (#1477) 2025-06-26 10:21:55 +02:00
Huang Xin 73878c14e3 fix: EPUB covers occasionally fail to display in library view, closes #1029 (#1476) 2025-06-26 10:03:35 +02:00
Huang Xin bc2c3a2c9a feat: add options to reset settings in the config dialog, closes #1111 (#1475) 2025-06-26 09:59:34 +02:00
Huang Xin 460d37c656 chore: update assetlinks and deeplink for Android (#1474) 2025-06-25 14:47:50 +02:00
Huang Xin fff9e3221e chore: use default production supabase and posthog if not customized, closes #1454 (#1473) 2025-06-25 13:46:48 +02:00
Huang Xin f77fa17cbc bump: update tauri to latest dev branch, closes #1468 (#1471) 2025-06-25 05:36:45 +02:00
Huang Xin d4565f7d60 feat: add an option in the view menu to crop or fit book covers of the library page (#1469) 2025-06-25 03:48:08 +02:00
Huang Xin e8b1976f4f doc: adjust the priority of the readest koplugin for Koreader (#1467) 2025-06-24 08:34:31 +02:00
Huang Xin 6b4bb64885 feat: custom css for the reader UI (#1466) 2025-06-24 08:33:49 +02:00
Huang Xin fc0c42f7db android: fix transparent icon background on Android, closes #1462 (#1464) 2025-06-24 06:54:49 +02:00
Huang Xin 41e5f27d4d layout: view menu now has a maximum width, closes #1459 (#1463)
* refactor: auth callback page

* layout: view menu now has a maximum width, closes #1459
2025-06-24 05:44:03 +02:00
Huang Xin a042b4c739 fix: longer timeout to save changes before reloading page (#1461) 2025-06-24 05:01:46 +02:00
Huang Xin a1c1a9f983 chore: bump opennextjs and wrangler to latest versions (#1460) 2025-06-24 03:22:12 +02:00
Huang Xin 423ae77160 doc: add the Support section in README (#1458) 2025-06-23 12:46:08 +02:00
Huang Xin 05c61a4b1a doc: add donation badge and link with crypto payment (#1457) 2025-06-23 11:37:13 +02:00
Huang Xin ce8f06b7fb chore: add sponsor readest link 2025-06-23 05:03:21 +02:00
Huang Xin f815844577 release: version 0.9.60 (#1452) 2025-06-22 11:24:47 +02:00
Huang Xin 7fe4d38c51 fix: allow inline scripts on Tauri platforms (#1451) 2025-06-22 11:22:39 +02:00
Huang Xin 43edcb4e9e android: dismiss the splash screen and change icon background color in task list (#1450)
* layout: various layout fix on iPad

* android: dismiss the splash screen and change icon background color in task list, closes #1424
2025-06-22 09:10:03 +02:00
Huang Xin e259b29afa fix: disable translation when the primary language is not defined, closes #1443 (#1448) 2025-06-22 07:10:22 +02:00
Huang Xin cd5f57a004 layout: ignore system top/bottom safe insets when there is no header or footer, closes #1442 (#1447) 2025-06-22 06:32:23 +02:00
Huang Xin 8c0cdb0e22 layout: tweaks on layout for iPad (#1446) 2025-06-22 05:55:40 +02:00
初五 c0df9f1cb6 doc: correct spelling 'Themeing' to 'Theming' in README (#1444) 2025-06-22 08:19:21 +08:00
Huang Xin cb1ddaeedb fix: smoother orientation change in iOS, closes #1434 (#1441) 2025-06-21 17:11:45 +02:00
Huang Xin 4447dd7b9a css: fix unintended font size adjust in scrolled mode on iOS, closes #1438 (#1440) 2025-06-21 13:25:46 +02:00
Huang Xin dcb7602837 fix: importing books on iOS at first launch is more reliable now (#1439) 2025-06-21 13:17:24 +02:00
Huang Xin 60e48742fe chore: allow download url for installers (#1437) 2025-06-21 11:13:59 +02:00
Huang Xin dd43569778 chore: download releases using cloudflare cdn (#1436) 2025-06-21 10:36:51 +02:00
Huang Xin 8433dd6950 chore: upload release artifacts to a R2 release bucket (#1435) 2025-06-21 16:24:49 +08:00
Huang Xin 379802f580 release: ready for Google Play release, closes #1001 (#1433) 2025-06-20 16:48:53 +02:00
Huang Xin 2dcb5d7c1e sync: add network timeout of sync to 5 sec (#1432)
* sync: add network timeout of sync to 5 sec

* css: minor tweaks on headings and list
2025-06-20 16:45:10 +02:00
Huang Xin 98870d9f3e release: version 0.9.59 (#1429) 2025-06-19 17:10:14 +02:00
Huang Xin 31aeb2289c settings: more reasonable constraints for top and bottom margins (#1428) 2025-06-19 17:01:20 +02:00
Huang Xin 0f66ecbba1 layout: remove unnecessary inset for header on landscape screen in iOS (#1425) 2025-06-19 12:01:42 +02:00
Huang Xin f0d3c9dd4e fix: transform vw/vh to pixels correctly, closes #1420 (#1421)
* i18n: update translations

* fix: transform vw/vh to pixels correctly, closes #1420
2025-06-19 10:53:58 +02:00
Huang Xin 92aae340d9 settings: unset deprecated margin settings (#1419) 2025-06-18 18:56:41 +02:00
Huang Xin 3cf40039ee release: version 0.9.58 (#1418) 2025-06-18 18:23:09 +02:00
Huang Xin 7ed4937d9d settings: add an option to always show status bar (#1417) 2025-06-18 18:15:55 +02:00
Huang Xin b3593c0365 feat: support split-screen mode on iPad, closes #971 (#1416) 2025-06-18 15:54:46 +02:00
Huang Xin 6235202066 layout: fix issue where sidebars couldn’t be dragged to resize on iPad (#1415) 2025-06-18 15:43:51 +02:00
Huang Xin b3d1085ddf fix: now it's possible to have multiple columns in portrait mode, closes #1369 (#1413) 2025-06-18 14:25:32 +02:00
Huang Xin b2a4ddae60 feat: show annotation create time and various other UI fixes (#1412) 2025-06-18 13:17:18 +02:00
Huang Xin 42f50af27b tts: each book view now has its own tts controller (#1411) 2025-06-18 11:37:02 +02:00
Huang Xin 07f74d8858 settings: individually adjust top, bottom, left, and right margins on the reader page (#1410) 2025-06-18 10:55:24 +02:00
Huang Xin 9477789db1 layout: apply responsive safe area insets to the reader page, closes #1067 and closes #1212 (#1408)
This also fixes layout glitches when showing status bar on devices without display cutouts
2025-06-17 03:36:23 +02:00
Huang Xin 8a7d0e1b0d fix: annotation tools now works when TTS is enabled, closes #1403 (#1406) 2025-06-16 08:53:55 +02:00
Huang Xin 261ce95ed1 feat: add keyboard shortcut t to toggle TTS (#1405) 2025-06-16 08:50:10 +02:00
Huang Xin c5a3b44cbf chore: add NEXT_PUBLIC_API_BASE_URL in example env file (#1404) 2025-06-16 06:02:50 +02:00
Huang Xin ed68d25b15 fix: restore full view settings when reopening book (#1400) 2025-06-15 16:07:51 +02:00
Huang Xin f7f8872a13 tts: translation also when TTS is played in the background, closes #1383 (#1399) 2025-06-15 09:07:22 +02:00
Huang Xin be9802a936 release: version 0.9.57 (#1395) 2025-06-14 15:42:08 +02:00
Huang Xin 84328dcfb2 hotfix: resolve compatibility issues with Android Text-to-Speech API (#1394) 2025-06-14 15:39:12 +02:00
Huang Xin 89d48c72b0 release: version 0.9.56 (#1391) 2025-06-13 17:35:38 +02:00
Huang Xin 467e80cd4b fix: various fixes on text selector in TTS mode and normal image height (#1390) 2025-06-13 17:28:21 +02:00
Huang Xin 20955ec294 chore: handle client uncaught exceptions and show error message (#1389) 2025-06-13 15:24:24 +02:00
James Sizeland 3cae183f51 feat: add syntax highlighting (#1386)
* add highlightjs

* add github light and dark theme

* inject style classes

* fix typo

* remove conflicting style overrides

* add highlightjs function and language list

* add language select setting

* add language select input

* add watcher to rerender page on changes to language select

* lockfile update

* rename highlightCode variables

* reorder code languages alphabetically

* copy translation style for highlight selector

* move code highlighting below theme color section

* move highlighting toggle logic into settings component

* add conditional statement to docLoadHandler manageSyntaxHighlighting

* reorder imports

* switch to media query for dark mode

* combine useEffect callbacks for code highlighting

* add feature line about code highlighting

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

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

* layout: fix drag handler for multiple dialog instances
2025-06-07 07:55:36 +02:00
Huang Xin 5bdc29fe84 perf: optimize by removing unnecessary animations on Android, closes #1360 (#1365) 2025-06-07 06:53:53 +02:00
Huang Xin 56f4b275f4 translator: toast for Daily Quota Exceeded error (#1363) 2025-06-06 16:24:14 +02:00
Huang Xin 00b46dd8bb bump: upgrade next.js to latest version (#1362) 2025-06-06 15:40:33 +02:00
Huang Xin 8ce89ca8f6 bump: upgrade supabase.js to latest version (#1361) 2025-06-06 14:37:29 +02:00
Huang Xin 648d9ec260 css: unset justify text align when overriding layout, closes #1338 (#1358) 2025-06-06 12:08:53 +02:00
Huang Xin ebe1c10c84 sync: also update deleted notes in synchronization, closes #1356 (#1357) 2025-06-06 11:47:26 +02:00
Huang Xin f2309ef496 release: version 0.9.53 (#1351) 2025-06-05 17:42:22 +02:00
Huang Xin 8d71593f39 fix: exit select mode when all books are deleted, closes #1347 (#1350) 2025-06-05 17:26:55 +02:00
Huang Xin 09e3c30a2a feat: add daily translation quota of DeepL API for more sustainable service (#1349) 2025-06-05 16:58:07 +02:00
Huang Xin eee4cbeaca fix: avoid additional click after books is downloaded before opening (#1346) 2025-06-05 11:43:46 +02:00
Huang Xin 00deca5029 bump: upgrade opennext and wrangler to latest versions (#1345) 2025-06-05 10:46:52 +02:00
Huang Xin c7c20fec55 epub: last resort to get the first image in manifest as book cover (#1343) 2025-06-05 08:40:48 +02:00
Huang Xin 57e22dc7b6 layout: use system select widget for language selection, closes #1339 (#1342) 2025-06-05 08:19:24 +02:00
Huang Xin debd60fb70 css: inline image only as direct children of p and span, closes #1340 (#1341) 2025-06-05 07:47:56 +02:00
Huang Xin dfa17c1c0b css: auto height for single images in anchor (#1337) 2025-06-04 20:08:16 +02:00
Huang Xin 2a26f8b04a fix: trigger library update for the books counter after importing books (#1336) 2025-06-04 19:02:49 +02:00
Huang Xin db955d2c8c feat: add an option to override book fg/bg color, closes #1328 (#1335) 2025-06-04 18:58:53 +02:00
Huang Xin 51602a77fb tts: disable media session controls to keep alive tts (#1333) 2025-06-04 08:17:33 +02:00
Huang Xin 48da5ab546 fix: update current bookshelf after importing and deleting books (#1331) 2025-06-03 19:33:23 +02:00
Huang Xin 363b98dd3b release: version 0.9.52 (#1327) 2025-06-03 15:49:04 +02:00
Huang Xin 43532a0ee8 feat: add an option to show remaining in minutes of the current chapter, closes #406 (#1326) 2025-06-03 15:37:55 +02:00
Huang Xin 03b2af1f97 chore: bump pdf.js to latest release of version 4 (#1325) 2025-06-03 13:22:52 +02:00
Huang Xin 3f8d03ae28 sync: add sync status menu item in view menu, closes #844 (#1324) 2025-06-03 11:43:58 +02:00
Huang Xin 146a71fdef font: load CJK fonts only in CJK env or the book has CJK languages, closes #1298 (#1323) 2025-06-03 08:55:40 +02:00
Huang Xin 734e22d92c layout: notebook layout tweaks (#1319) 2025-06-02 17:27:02 +02:00
Huang Xin a77986c0e6 feat: add search functionality to the notebook (#1318) 2025-06-02 16:03:55 +02:00
Huang Xin 4e7f9c49b0 layout: render book description as html in book details, closes #1316 (#1317) 2025-06-02 14:25:51 +02:00
Huang Xin c51c95b883 feat: support note taking with markdown, closes #1097 (#1315) 2025-06-02 11:35:09 +02:00
Huang Xin 62081bebfd fix: update current bookshelf items when library is updated (#1314) 2025-06-02 09:57:11 +02:00
Huang Xin 15394d17d8 feat: show current books count in search bar, closes #1310 (#1312) 2025-06-02 09:31:35 +02:00
Huang Xin 5d1deadc1e fix: defers rendering all component until after hydration for Tauri platforms, closes #1301 (#1311) 2025-06-02 08:25:08 +02:00
Huang Xin b497ddacac css: add margin for div only when overriding layout, closes #1286 (#1309) 2025-06-02 07:59:58 +02:00
Huang Xin a9965fd52c config: add an option to sort TOC by page number, closes #1304 (#1308) 2025-06-02 07:23:59 +02:00
Huang Xin 87d610bb62 revert: some browsers may report wrong pixel density, closes #1305 (#1306)
This reverts commit 6e0cc38aa7ca726e251c16e453922fcd637cee49.
2025-06-02 06:19:13 +02:00
Huang Xin dbc4651141 chore: parse webkit version on linux (#1299) 2025-06-01 04:57:53 +02:00
Huang Xin dad2dfd078 i18n: update translations (#1297) 2025-05-31 18:58:43 +02:00
Huang Xin 1fe188814d release: version 0.9.51 (#1296) 2025-05-31 18:33:21 +02:00
Huang Xin 6ec3a5b332 feat: add an option to enable JavaScript in EPUB, closes #1278 (#1295) 2025-05-31 18:25:25 +02:00
Huang Xin 1acba2e08c fix: notebook now won't prevent pagination, closes #1285 (#1294) 2025-05-31 15:02:36 +02:00
Huang Xin 32f9346f84 tts: more robust way to save last speaking location for tts (#1293) 2025-05-31 13:34:35 +02:00
Huang Xin 69c503026a bump: catch up foliate-js upstream (#1292) 2025-05-31 13:31:27 +02:00
Huang Xin 84fc4cec5e tts: read from last read sentence in tts (#1291) 2025-05-31 10:53:36 +02:00
Huang Xin 5c9c11fafd translator: disable translator if the system language is the same with the book language (#1290)
Closes #1288
2025-05-31 09:34:52 +02:00
Huang Xin 77fa57438e tts: show speaking sentence and chapter info in tts media session (#1289) 2025-05-31 09:21:17 +02:00
Huang Xin e4d217f3aa config: add theme mode options in the library page (#1283) 2025-05-30 09:10:48 +02:00
Huang Xin c7a583c53a translator: lazy load translation observer (#1282) 2025-05-30 09:08:45 +02:00
Huang Xin 6c86917098 translator: add daily translation quota for deepl (#1275) 2025-05-29 19:24:09 +02:00
Huang Xin 7c21f48d68 layout: hide the header bar only on mobile platforms when translation is enabled (#1274) 2025-05-29 10:31:02 +02:00
Huang Xin 5820191c26 translator: also translate table of contents (#1273) 2025-05-29 10:08:01 +02:00
Huang Xin b37a804192 css: enhanced compatibility with the text-indent CSS property, closes #1267 (#1271) 2025-05-29 08:53:58 +02:00
211 changed files with 9717 additions and 3692 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: ['readest']
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+5
View File
@@ -177,6 +177,11 @@ 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
+60
View File
@@ -0,0 +1,60 @@
name: Upload Release Assets to R2
on:
workflow_call:
inputs:
tag:
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: 'Release tag name (e.g., v1.2.3)'
required: true
type: string
jobs:
upload-to-r2:
runs-on: ubuntu-latest
env:
RELEASE_R2_BUCKET: readest-releases
RELEASE_R2_ACCOUNT_ID: ${{ secrets.RELEASE_R2_ACCOUNT_ID }}
RELEASE_R2_ACCESS_KEY_ID: ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
RELEASE_R2_SECRET_ACCESS_KEY: ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
steps:
- name: Download release assets
run: |
gh release download "${{ inputs.tag }}" --repo readest/readest --dir ./release-assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install rclone
run: curl https://rclone.org/install.sh | sudo bash
- name: Configure rclone
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = $RELEASE_R2_ACCESS_KEY_ID
secret_access_key = $RELEASE_R2_SECRET_ACCESS_KEY
endpoint = https://${RELEASE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com
EOF
- name: Modify latest.json download URLs
run: |
GITHUB_BASE_URL="https://github.com/readest/readest/releases/download"
READEST_BASE_URL="https://download.readest.com/releases"
sed -i "s#${GITHUB_BASE_URL}#${READEST_BASE_URL}#g" ./release-assets/latest.json
- name: Upload to R2
run: |
mkdir releases
mv ./release-assets/latest.json releases
mv ./release-assets/release-notes.json releases
rclone copy ./release-assets r2:${RELEASE_R2_BUCKET}/releases/${{ inputs.tag }}/
rclone copy ./releases r2:${RELEASE_R2_BUCKET}/releases/
+9
View File
@@ -4,4 +4,13 @@
"packages/tauri/Cargo.toml",
"apps/readest-app/src-tauri/Cargo.toml"
],
// "editor.formatOnSave": true, // uncomment to add format on save
"typescript.inlayHints.parameterNames.enabled": "all",
"typescript.inlayHints.variableTypes.enabled": true,
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
"typescript.inlayHints.enumMemberValues.enabled": true,
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.format.enable": false,
}
Generated
+591 -669
View File
File diff suppressed because it is too large Load Diff
+24 -3
View File
@@ -15,6 +15,8 @@
[![][badge-discord]][link-discord]
[![AGPL Licence][badge-license]](LICENSE)
[![Latest release][badge-release]][link-gh-releases]
[![Donate][badge-donate]][link-donate]
<br>
[![Last commit][badge-last-commit]][link-gh-commits]
[![Commits][badge-commit-activity]][link-gh-pulse]
@@ -27,6 +29,7 @@
<a href="#downloads">Downloads</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#troubleshooting">Troubleshooting</a> •
<a href="#support">Support</a> •
<a href="#license">License</a>
</p>
@@ -55,6 +58,7 @@
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
## Planned Features
@@ -63,8 +67,8 @@
| **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. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **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. | 🔄 |
@@ -86,7 +90,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
![Themeing Dark Mode](./data/screenshots/theming_dark_mode.png)
![Theming Dark Mode](./data/screenshots/theming_dark_mode.png)
---
@@ -94,7 +98,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
The Readest app is available for download! 🥳 🚀
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to readestapp@gmail.com to request access).
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Web: Visit [https://web.readest.com][link-web-readest].
@@ -242,6 +246,20 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
</p>
</a>
## Support
If Readest has been useful to you, consider supporting its development. Your contribution helps us squash bugs faster, improve performance, and keep building great features.
### How to Donate
1. **GitHub Sponsors**
Back the project directly on GitHub:
👉 [https://github.com/sponsors/readest](https://github.com/sponsors/readest)
2. **Crypto Donations**
Prefer crypto? You can donate here:
👉 [https://donate.readest.com/](https://donate.readest.com/)
## License
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
@@ -253,6 +271,7 @@ The following libraries and frameworks are used in this software:
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
- [marked](https://github.com/markedjs/marked), which is MIT licensed.
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
- [react](https://github.com/facebook/react), which is MIT licensed.
@@ -275,6 +294,8 @@ The following fonts are utilized in this software, either bundled within the app
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[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
[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
[link-web-readest]: https://web.readest.com
+5 -2
View File
@@ -2,5 +2,8 @@ PDFJS_BUILD_PATH=../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build
PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
NEXT_PUBLIC_DEV_SUPABASE_URL=https://gxkhxxxeapexynpouyjz.supabase.co
NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd4a2h4eHhlYXBleHlucG91eWp6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ0MzAwNTksImV4cCI6MjA1MDAwNjA1OX0.jhinkQsimQoidsg_U59YD5ROw4PmMJQNKuyXbr4TiQA
NEXT_PUBLIC_DEFAULT_POSTHOG_URL_BASE64="aHR0cHM6Ly91cy5pLnBvc3Rob2cuY29t"
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX2ViNXowbVRxWm8yZm5YYnZGNmE3bFh5TThpTmRSNTNsR1A3VFM3VGh4S08="
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64="aHR0cHM6Ly9yZWFkZXN0LnN1cGFiYXNlLmNv"
NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64="ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
+2
View File
@@ -6,6 +6,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824
NEXT_PUBLIC_API_BASE_URL=https://your-api-base-url.com
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
+5
View File
@@ -1,8 +1,13 @@
import withPWAInit from '@ducanh2912/next-pwa';
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
const isDev = process.env['NODE_ENV'] === 'development';
const appPlatform = process.env['NEXT_PUBLIC_APP_PLATFORM'];
if (isDev) {
initOpenNextCloudflareForDev();
}
/** @type {import('next').NextConfig} */
const nextConfig = {
// Ensure Next.js uses SSG instead of SSR
+5 -28
View File
@@ -1,29 +1,6 @@
// @ts-nocheck
// default open-next.config.ts file created by @opennextjs/cloudflare
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js';
import cache from '@opennextjs/cloudflare/kv-cache';
const config: OpenNextConfig = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
// set `incrementalCache` to "dummy" to disable KV cache
incrementalCache: async () => cache,
tagCache: 'dummy',
queue: 'dummy',
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
},
},
};
export default config;
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});
+29 -21
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.50",
"version": "0.9.61",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -30,9 +30,11 @@
"build-ios-appstore": "dotenv -e .env.ios-appstore.local -- tauri ios build --export-method app-store-connect",
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh",
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler dev",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler deploy",
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
},
"dependencies": {
@@ -40,34 +42,38 @@
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.3.1",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.5.0",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-haptics": "^2.2.4",
"@tauri-apps/plugin-http": "^2.4.3",
"@tauri-apps/plugin-log": "^2.4.0",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "~2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@supabase/supabase-js": "^2.49.10",
"@tauri-apps/api": "2.6.0",
"@tauri-apps/plugin-cli": "^2.3.0",
"@tauri-apps/plugin-deep-link": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "^2.3.0",
"@tauri-apps/plugin-haptics": "^2.2.5",
"@tauri-apps/plugin-http": "^2.4.4",
"@tauri-apps/plugin-log": "^2.5.1",
"@tauri-apps/plugin-opener": "^2.3.1",
"@tauri-apps/plugin-os": "^2.2.2",
"@tauri-apps/plugin-process": "^2.2.2",
"@tauri-apps/plugin-shell": "~2.2.2",
"@tauri-apps/plugin-updater": "^2.8.1",
"@zip.js/zip.js": "^2.7.53",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cssbeautify": "^0.3.1",
"dayjs": "^1.11.13",
"foliate-js": "workspace:*",
"highlight.js": "^11.11.1",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"next": "15.2.4",
"marked": "^15.0.12",
"next": "15.3.3",
"posthog-js": "^1.246.0",
"react": "19.0.0",
"react-color": "^2.19.3",
@@ -75,19 +81,21 @@
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-responsive": "^10.0.0",
"react-window": "^1.8.11",
"semver": "^7.7.1",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
},
"devDependencies": {
"@opennextjs/cloudflare": "^0.5.12",
"@tauri-apps/cli": "2.5.0",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.6.0",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
"@types/react-window": "^1.8.8",
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
@@ -105,6 +113,6 @@
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"wrangler": "^4.4.0"
"wrangler": "^4.21.0"
}
}
@@ -1,11 +1,15 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.bilingify.readest",
"sha256_cert_fingerprints": [
"65:2D:11:67:76:12:29:14:18:42:CB:3D:18:50:B6:E4:7E:46:E1:2F:4B:E4:7F:5A:6C:14:B6:D7:12:74:1E:82"
"65:2D:11:67:76:12:29:14:18:42:CB:3D:18:50:B6:E4:7E:46:E1:2F:4B:E4:7F:5A:6C:14:B6:D7:12:74:1E:82",
"E0:E7:60:55:80:8D:3A:DE:A0:D1:CF:7C:20:85:40:A3:DD:4B:E6:4D:17:5C:0F:DE:26:57:7D:9C:5B:29:5F:51"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable
@@ -18,8 +18,7 @@
"Confirm Deletion": "تأكيد الحذف",
"Copied to notebook": "تم النسخ إلى المفكرة",
"Copy": "نسخ",
"Custom CSS": "CSS مخصص",
"Dark Mode": "الوضع المظلم",
"Dark Mode": "الوضع الداكن",
"Default": "الافتراضي",
"Default Font": "الخط الافتراضي",
"Default Font Size": "حجم الخط الافتراضي",
@@ -28,7 +27,6 @@
"Dictionary": "القاموس",
"Download Readest": "تحميل ريديست",
"Edit": "تحرير",
"Enter your custom CSS here...": "أدخل الـ CSS المخصص هنا...",
"Excerpts": "مقتطفات",
"Failed to import book(s): {{filenames}}": "فشل استيراد الكتاب/الكتب: {{filenames}}",
"Fast": "سريع",
@@ -114,8 +112,6 @@
"Your Library": "المكتبة الخاصة بك",
"TTS not supported for PDF": "القراءة الصوتية غير مدعومة لملفات PDF",
"Override Book Font": "تجاوز خط الكتاب",
"Vertical Margins (px)": "هوامش عمودية (بكسل)",
"Horizontal Margins (%)": "هوامش أفقية (%)",
"Apply to All Books": "تطبيق على جميع الكتب",
"Apply to This Book": "تطبيق على هذا الكتاب",
"Unable to fetch the translation. Try again later.": "تعذر جلب الترجمة. حاول مرة أخرى لاحقًا.",
@@ -186,28 +182,27 @@
"Sign in with Apple": "تسجيل الدخول عبر Apple",
"Sign in with GitHub": "تسجيل الدخول عبر GitHub",
"Account": "الحساب",
"Cloud Storage": "التخزين السحابي",
"Failed to delete user. Please try again later.": "فشل حذف المستخدم. يرجى المحاولة مرة أخرى لاحقًا.",
"Unlimited Offline/Online Reading": "قراءة غير محدودة بدون اتصال/متصلة بالإنترنت",
"Unlimited Cloud Sync Devices": "أجهزة مزامنة سحابية غير محدودة",
"Unlimited Offline/Online Reading": "القراءة بدون حدود للكتب المتوفر على الجهاز أو عبر الانترنت",
"Unlimited Cloud Sync Devices": "مزامنة عبر السحابة لعدد غير محدود من الأجهزة",
"Essential Text-to-Speech Voices": "أصوات أساسية لتحويل النص إلى كلام",
"DeepL Free Access": "وصول مجاني إلى DeepL",
"DeepL Free Access": "وصول مجاني لـ DeepL",
"Community Support": "دعم المجتمع",
"500 MB Cloud Sync Space": ساحة مزامنة سحابية 500 ميجابايت",
"Includes All Free Tier Benefits": "يتضمن جميع مزايا الفئة المجانية",
"500 MB Cloud Sync Space": "مزامنة سحابية بمساحة تخزين 500 ميجابايت",
"Includes All Free Tier Benefits": "تشمل جميع مزايا الفئة المجانية",
"AI Summaries": "ملخصات الذكاء الاصطناعي",
"AI Translations": "ترجمات الذكاء الاصطناعي",
"Priority Support": "دعم ذو أولوية",
"DeepL Pro Access": "وصول إلى DeepL Pro",
"DeepL Pro Access": "وصول لميزات DeepL Pro",
"Expanded Text-to-Speech Voices": "أصوات موسعة لتحويل النص إلى كلام",
"2000 MB Cloud Sync Space": ساحة مزامنة سحابية 2000 ميجابايت",
"Includes All Plus Tier Benefits": "يتضمن جميع مزايا فئة Plus",
"2000 MB Cloud Sync Space": "مزامنة سحابية بمساحة تخزين 2000 ميجابايت",
"Includes All Plus Tier Benefits": "تشمل جميع مزايا فئة Plus",
"Unlimited AI Summaries": "ملخصات ذكاء اصطناعي غير محدودة",
"Unlimited AI Translations": "ترجمات ذكاء اصطناعي غير محدودة",
"Unlimited DeepL Pro Access": "وصول غير محدود إلى DeepL Pro",
"Unlimited DeepL Pro Access": "وصول غير محدود لميزات DeepL Pro",
"Advanced AI Tools": "أدوات ذكاء اصطناعي متقدمة",
"Early Feature Access": "وصول مبكر للميزات",
"10 GB Cloud Sync Space": ساحة مزامنة سحابية 10 جيجابايت",
"10 GB Cloud Sync Space": "مزامنة سحابية بمساحة تخزين 10 جيجابايت",
"Loading profile...": "جارٍ تحميل الملف الشخصي...",
"Features": "الميزات",
"Delete Account": "حذف الحساب",
@@ -223,7 +218,7 @@
"Continuous Scroll": "التمرير المستمر",
"Fullscreen": "ملء الشاشة",
"No supported files found. Supported formats: {{formats}}": "لم يتم العثور على ملفات مدعومة. الصيغ المدعومة: {{formats}}",
"Drop to Import Books": "اسحب لاستيراد الكتب",
"Drop to Import Books": "قم بالسحب والإسقاط لاستيراد الكتب",
"Custom": "مخصص",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "العالم مسرح،\nوالناس فيه ممثلون؛\nلهم مخارج ومداخل،\nوالإنسان في حياته يلعب دورًا كثيرًا،\nوأفعاله تمر بسبع مراحل.\n\n— ويليام شكسبير",
"Custom Theme": "سمة مخصصة",
@@ -236,8 +231,8 @@
"Double Border": "حدود مزدوجة",
"Border Color": "لون الحدود",
"Border Frame": "إطار الحدود",
"Show Header": "عرض الرأس",
"Show Footer": "عرض التذييل",
"Show Header": "إظهار الترويسة",
"Show Footer": "إظهار التذييل",
"Small": "صغير",
"Large": "كبير",
"Auto": "تلقائي",
@@ -252,24 +247,24 @@
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"No chapters detected.": "لم يتم التعرف على فصول.",
"No chapters detected.": "لم يتم التعرف على أي فصول.",
"Failed to parse the EPUB file.": "فشل في تحليل ملف EPUB.",
"This book format is not supported.": "هذا تنسيق الكتاب غير مدعوم.",
"This book format is not supported.": "تنسيق الكتاب هذا غير مدعوم.",
"Unable to fetch the translation. Please log in first and try again.": "تعذر جلب الترجمة. يرجى تسجيل الدخول أولاً ثم المحاولة مرة أخرى.",
"Group": "تجميع",
"Always on Top": "دائمًا في المقدمة",
"No Timeout": "لا مهلة",
"No Timeout": "بدون مهلة",
"{{value}} minute": "{{value}} دقيقة",
"{{value}} minutes": "{{value}} دقائق",
"{{value}} hour": "{{value}} ساعة",
"{{value}} hours": "{{value}} ساعات",
"CJK Font": "خط CJK",
"Clear Search": "مسح البحث",
"Header & Footer": "الرأس والتذييل",
"Header & Footer": "الترويسة والتذييل",
"Apply also in Scrolled Mode": "تطبيق أيضًا في وضع التمرير",
"A new version of Readest is Available!": "إصدار جديد من ريديست متاح!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "إصدار ريديست {{newVersion}} متاح (الإصدار المثبت {{currentVersion}}).",
"Download and install now?": "هل ترغب في تنزيل وتثبيت الآن؟",
"A new version of Readest is available!": "يتوفر إصدار جديد من Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "إصدار {{newVersion}} من ريديست متاح للتنزيل (الإصدار المثبت حاليًا {{currentVersion}}).",
"Download and install now?": "هل ترغب في تنزيله وتثبيته الآن؟",
"Downloading {{downloaded}} of {{contentLength}}": "جارٍ تنزيل {{downloaded}} من {{contentLength}}",
"Download finished": "اكتمل التنزيل",
"DOWNLOAD & INSTALL": "تنزيل وتثبيت",
@@ -296,8 +291,8 @@
"Swap Clicks Area": "تبديل منطقة النقرات",
"Screen": "الشاشة",
"Orientation": "الاتجاه",
"Portrait": "العمودي",
"Landscape": "الأفقي",
"Portrait": "عمودي",
"Landscape": "أفقي",
"Open Last Book on Start": "فتح آخر كتاب عند البدء",
"Checking for updates...": "جارٍ التحقق من التحديثات...",
"Error checking for updates": "خطأ في التحقق من التحديثات",
@@ -315,14 +310,14 @@
"Are you sure to delete {{count}} selected book(s)?_many": "هل أنت متأكد من حذف {{count}} كتابًا محددًا؟",
"Are you sure to delete {{count}} selected book(s)?_other": "هل أنت متأكد من حذف {{count}} من الكتب المحددة؟",
"Are you sure to delete the selected book?": "هل أنت متأكد من حذف الكتاب المحدد؟",
"Deselect": "إلغاء",
"Select All": "الكل",
"Deselect": "إلغاء التحديد",
"Select All": "تحديد الكل",
"No translation available.": "لا توجد ترجمة متاحة.",
"Translated by {{provider}}.": "ترجم بواسطة {{provider}}.",
"DeepL": "ديب إل",
"Google Translate": "ترجمة جوجل",
"Azure Translator": "مترجم أزور",
"Invert Image In Dark Mode": "عكس الصورة في الوضع المظلم",
"Translated by {{provider}}.": مت الترجمة بواسطة {{provider}}.",
"DeepL": "DeepL",
"Google Translate": "ترجمة Google",
"Azure Translator": "مترجم Azure",
"Invert Image In Dark Mode": "عكس ألوان الصورة في الوضع الداكن",
"Help improve Readest": "ساعد في تحسين ريديست",
"Sharing anonymized statistics": "مشاركة إحصائيات مجهولة الهوية",
"Interface Language": "لغة الواجهة",
@@ -334,5 +329,66 @@
"Scroll": "تمرير",
"Overlap Pixels": "تداخل البكسلات",
"Click": "نقر",
"System Language": "لغة النظام"
"System Language": "لغة النظام",
"Security": "الأمان",
"Allow JavaScript": "السماح بـ JavaScript",
"Enable only if you trust the file.": "قم بالتمكين فقط في حال وثوقك بالملف.",
"Sort TOC by Page": "ترتيب جدول المحتويات حسب الصفحة",
"Search in {{count}} Book(s)..._zero": "بحث في الكتب...",
"Search in {{count}} Book(s)..._one": "بحث في كتاب...",
"Search in {{count}} Book(s)..._two": "بحث في كتابين...",
"Search in {{count}} Book(s)..._few": "بحث في {{count}} كتب...",
"Search in {{count}} Book(s)..._many": "بحث في {{count}} كتابًا...",
"Search in {{count}} Book(s)..._other": "بحث في {{count}} من الكتب...",
"No notes match your search": "لا توجد ملاحظات تطابق بحثك",
"Search notes and excerpts...": "بحث في الملاحظات والمقتطفات...",
"Sign in to Sync": "قم بتسجيل الدخول للمزامنة",
"Synced at {{time}}": "تمت المزامنة في {{time}}",
"Never synced": "لم تتم المزامنة بعد",
"Show Remaining Time": "إظهار الوقت المتبقي",
"Show Page Number": "إظهار رقم الصفحة",
"{{time}} min left in chapter": "{{time}} دقيقة متبقية حتى نهاية الفصل",
"Override Book Color": "تجاوز لون الكتاب",
"Login Required": "يتطلب الأمر تسجيل الدخول",
"Quota Exceeded": "تجاوزت الحصة المخصصة",
"{{percentage}}% of Daily Translation Characters Used.": "تم استخدام ما نسبته {{percentage}}% من إجمالي أحرف الترجمة المسموح بها يوميًا.",
"Translation Characters": "أحرف الترجمة",
"Daily translation quota reached. Select another translate service to proceed.": "تم استنفاد حصة الترجمة اليومية. قم بتحديد خدمة ترجمة أخرى للاستمرار.",
"{{engine}}: {{count}} voices_zero": "{{engine}}: لا توجد أصوات",
"{{engine}}: {{count}} voices_one": "{{engine}}: صوت واحد",
"{{engine}}: {{count}} voices_two": "{{engine}}: صوتان",
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} أصوات",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} صوتاً",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} صوت",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "حدث خطأ ما. لا تقلق، لقد تم إبلاغ فريقنا ونحن نعمل على إصلاحه.",
"Error Details:": "تفاصيل الخطأ:",
"Try Again": "حاول مرة أخرى",
"Need help?": "تحتاج مساعدة؟",
"Contact Support": "اتصل بالدعم الفني",
"Code Highlighting": "تمييز الكود",
"Enable Highlighting": "تمكين التمييز",
"Code Language": "لغة الكود",
"Top Margin (px)": "الهامش العلوي (بكسل)",
"Bottom Margin (px)": "الهامش السفلي (بكسل)",
"Right Margin (px)": "الهامش الأيمن (بكسل)",
"Left Margin (px)": "الهامش الأيسر (بكسل)",
"Column Gap (%)": "تباعد الأعمدة (%)",
"Always Show Status Bar": "دائمًا إظهار شريط الحالة",
"Translation Not Available": "الترجمة غير متاحة",
"Custom Content CSS": "CSS مخصص للمحتوى",
"Enter CSS for book content styling...": "أدخل CSS لتنسيق محتوى الكتاب...",
"Custom Reader UI CSS": "CSS مخصص لواجهة القارئ",
"Enter CSS for reader interface styling...": "أدخل CSS لتنسيق واجهة القارئ...",
"Crop": "قص",
"Book Covers": "أغلفة الكتب",
"Fit": "ملاءمة",
"Reset {{settings}}": "إعادة تعيين {{settings}}",
"Reset Settings": "إعادة تعيين الإعدادات",
"{{count}} pages left in chapter_zero": "لم يتبق أي صفحات في هذا الفصل",
"{{count}} pages left in chapter_one": "تبقّت صفحة واحدة في هذا الفصل",
"{{count}} pages left in chapter_two": "تبقّت صفحتان في هذا الفصل",
"{{count}} pages left in chapter_few": "تبقّت {{count}} صفحات في هذا الفصل",
"{{count}} pages left in chapter_many": "تبقّت {{count}} صفحة في هذا الفصل",
"{{count}} pages left in chapter_other": "تبقّى {{count}} صفحة في هذا الفصل",
"Show Remaining Pages": "عرض الصفحات المتبقية"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Löschen bestätigen",
"Copied to notebook": "In Notizbuch kopiert",
"Copy": "Kopieren",
"Custom CSS": "Benutzerdefiniertes CSS",
"Dark Mode": "Dunkelmodus",
"Default": "Standard",
"Default Font": "Standardschriftart",
@@ -28,7 +27,6 @@
"Dictionary": "Wörterbuch",
"Download Readest": "Readest herunterladen",
"Edit": "Bearbeiten",
"Enter your custom CSS here...": "Geben Sie hier Ihr benutzerdefiniertes CSS ein...",
"Excerpts": "Auszüge",
"Failed to import book(s): {{filenames}}": "Fehler beim Importieren von Buch/Büchern: {{filenames}}",
"Fast": "Schnell",
@@ -114,8 +112,6 @@
"Your Library": "Ihre Bibliothek",
"TTS not supported for PDF": "TTS wird für PDF nicht unterstützt",
"Override Book Font": "Buch-Schriftart überschreiben",
"Vertical Margins (px)": "Vertikale Ränder (px)",
"Horizontal Margins (%)": "Horizontale Ränder (%)",
"Apply to All Books": "Auf alle Bücher anwenden",
"Apply to This Book": "Auf dieses Buch anwenden",
"Unable to fetch the translation. Try again later.": "Übersetzung konnte nicht abgerufen werden. Versuchen Sie es später erneut.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Mit Apple anmelden",
"Sign in with GitHub": "Mit GitHub anmelden",
"Account": "Konto",
"Cloud Storage": "Cloud-Speicher",
"Failed to delete user. Please try again later.": "Benutzer konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut.",
"Unlimited Offline/Online Reading": "Unbegrenztes Offline/Online Lesen",
"Unlimited Cloud Sync Devices": "Unbegrenzte Cloud-Synchronisierungsgeräte",
@@ -267,7 +262,7 @@
"Clear Search": "Suche löschen",
"Header & Footer": "Kopf- & Fußzeile",
"Apply also in Scrolled Mode": "Auch im Scroll-Modus anwenden",
"A new version of Readest is Available!": "Eine neue Version von Readest ist verfügbar!",
"A new version of Readest is available!": "Eine neue Version von Readest ist verfügbar!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} ist verfügbar (installierte Version {{currentVersion}}).",
"Download and install now?": "Jetzt herunterladen und installieren?",
"Downloading {{downloaded}} of {{contentLength}}": "Herunterladen {{downloaded}} von {{contentLength}}",
@@ -330,5 +325,54 @@
"Scroll": "Scrollen",
"Overlap Pixels": "Überlappungspixel",
"Click": "Tippen",
"System Language": "Systemsprache"
"System Language": "Systemsprache",
"Security": "Sicherheit",
"Allow JavaScript": "JavaScript zulassen",
"Enable only if you trust the file.": "Nur aktivieren, wenn Sie der Datei vertrauen.",
"Sort TOC by Page": "TOC nach Seite sortieren",
"Search in {{count}} Book(s)..._one": "Suche in {{count}} Buch...",
"Search in {{count}} Book(s)..._other": "Suche in {{count}} Büchern...",
"No notes match your search": "Noch keine Notizen gefunden",
"Search notes and excerpts...": "Notizen und Auszüge suchen...",
"Sign in to Sync": "Anmelden zum Synchronisieren",
"Synced at {{time}}": "Synchronisiert um {{time}}",
"Never synced": "Nie synchronisiert",
"Show Remaining Time": "Verbleibende Zeit anzeigen",
"Show Page Number": "Seitenzahl anzeigen",
"{{time}} min left in chapter": "{{time}} min verbleibend im Kapitel",
"Override Book Color": "Farbe des Buches überschreiben",
"Login Required": "Anmeldung nötig",
"Quota Exceeded": "Limit überschritten",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% der täglichen Übersetzungszeichen verwendet.",
"Translation Characters": "Übersetzungszeichen",
"Daily translation quota reached. Select another translate service to proceed.": "Das tägliche Übersetzungslimit wurde erreicht. Wählen Sie einen anderen Übersetzungsdienst, um fortzufahren.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} Stimme",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} Stimmen",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Etwas ist schief gelaufen. Keine Sorge, unser Team wurde benachrichtigt und arbeitet an einer Lösung.",
"Error Details:": "Fehlerdetails:",
"Try Again": "Erneut versuchen",
"Need help?": "Brauchen Sie Hilfe?",
"Contact Support": "Support kontaktieren",
"Code Highlighting": "Code-Hervorhebung",
"Enable Highlighting": "Code-Hervorhebung aktivieren",
"Code Language": "Programmiersprache",
"Top Margin (px)": "Oberer Rand (px)",
"Bottom Margin (px)": "Unterer Rand (px)",
"Right Margin (px)": "Rechter Rand (px)",
"Left Margin (px)": "Linker Rand (px)",
"Column Gap (%)": "Spaltenabstand (%)",
"Always Show Status Bar": "Statusleiste immer anzeigen",
"Translation Not Available": "Übersetzung nicht verfügbar",
"Custom Content CSS": "Benutzerdefiniertes Inhalts-CSS",
"Enter CSS for book content styling...": "CSS für die Buchinhaltsgestaltung eingeben...",
"Custom Reader UI CSS": "Benutzerdefiniertes CSS für die Leseroberfläche",
"Enter CSS for reader interface styling...": "CSS für die Leseroberfläche eingeben...",
"Crop": "Zuschneiden",
"Book Covers": "Buchcover",
"Fit": "Anpassen",
"Reset {{settings}}": "{{settings}} zurücksetzen",
"Reset Settings": "Einstellungen zurücksetzen",
"{{count}} pages left in chapter_one": "{{count}} Seite verbleibend im Kapitel",
"{{count}} pages left in chapter_other": "{{count}} Seiten verbleibend im Kapitel",
"Show Remaining Pages": "Verbleibende Seiten anzeigen"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Επιβεβαίωση διαγραφής",
"Copied to notebook": "Αντιγράφηκε στο σημειωματάριο",
"Copy": "Αντιγραφή",
"Custom CSS": "Προσαρμοσμένο CSS",
"Dark Mode": "Σκοτεινή λειτουργία",
"Default": "Προεπιλογή",
"Default Font": "Προεπιλεγμένη γραμματοσειρά",
@@ -28,7 +27,6 @@
"Dictionary": "Λεξικό",
"Download Readest": "Λήψη Readest",
"Edit": "Επεξεργασία",
"Enter your custom CSS here...": "Εισάγετε το προσαρμοσμένο CSS εδώ...",
"Excerpts": "Αποσπάσματα",
"Failed to import book(s): {{filenames}}": "Αποτυχία εισαγωγής βιβλίου(ων): {{filenames}}",
"Fast": "Γρήγορα",
@@ -114,8 +112,6 @@
"Your Library": "Η βιβλιοθήκη σας",
"TTS not supported for PDF": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται για αρχεία PDF",
"Override Book Font": "Παράκαμψη γραμματοσειράς βιβλίου",
"Vertical Margins (px)": "Κάθετα περιθώρια (px)",
"Horizontal Margins (%)": "Οριζόντια περιθώρια (%)",
"Apply to All Books": "Εφαρμογή σε όλα τα βιβλία",
"Apply to This Book": "Εφαρμογή σε αυτό το βιβλίο",
"Unable to fetch the translation. Try again later.": "Αδυναμία λήψης της μετάφρασης. Δοκιμάστε ξανά αργότερα.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Σύνδεση με Apple",
"Sign in with GitHub": "Σύνδεση με GitHub",
"Account": "Λογαριασμός",
"Cloud Storage": "Αποθηκευτικός χώρος Cloud",
"Failed to delete user. Please try again later.": "Αποτυχία διαγραφής χρήστη. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"Unlimited Offline/Online Reading": "Απεριόριστη ανάγνωση εκτός/εντός σύνδεσης",
"Unlimited Cloud Sync Devices": "Απεριόριστες συσκευές συγχρονισμού Cloud",
@@ -267,7 +262,7 @@
"Clear Search": "Καθαρισμός αναζήτησης",
"Header & Footer": "Κεφαλίδα & Υποσέλιδο",
"Apply also in Scrolled Mode": "Εφαρμογή και σε λειτουργία κύλισης",
"A new version of Readest is Available!": "Μια νέα έκδοση του Readest είναι διαθέσιμη!",
"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}}": "Κατεβάζω {{downloaded}} από {{contentLength}}",
@@ -330,5 +325,54 @@
"Scroll": "Κύλιση",
"Overlap Pixels": "Επικάλυψη εικονοστοιχείων",
"Click": "Κλικ",
"System Language": "Γλώσσα συστήματος"
"System Language": "Γλώσσα συστήματος",
"Security": "Ασφάλεια",
"Allow JavaScript": "Επιτρέψτε JavaScript",
"Enable only if you trust the file.": "Ενεργοποιήστε μόνο εάν εμπιστεύεστε το αρχείο.",
"Sort TOC by Page": "Ταξινόμηση TOC κατά σελίδα",
"Search in {{count}} Book(s)..._one": "Αναζήτηση σε {{count}} Βιβλίο...",
"Search in {{count}} Book(s)..._other": "Αναζήτηση σε {{count}} Βιβλία...",
"No notes match your search": "Καμία σημείωση δεν βρέθηκε",
"Search notes and excerpts...": "Αναζήτηση σημειώσεων...",
"Sign in to Sync": "Σύνδεση για συγχρονισμό",
"Synced at {{time}}": "Συγχρονίστηκε στις {{time}}",
"Never synced": "Ποτέ δεν συγχρονίστηκε",
"Show Remaining Time": "Εμφάνιση υπολειπόμενου χρόνου",
"Show Page Number": "Εμφάνιση αριθμού σελίδας",
"{{time}} min left in chapter": "{{time}} λεπτά απομένουν στο κεφάλαιο",
"Override Book Color": "Παράκαμψη χρώματος βιβλίου",
"Login Required": "Απαιτείται σύνδεση",
"Quota Exceeded": "Υπέρβαση ορίου",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% των ημερήσιων χαρακτήρων μετάφρασης χρησιμοποιήθηκαν.",
"Translation Characters": "Χαρακτήρες μετάφρασης",
"Daily translation quota reached. Select another translate service to proceed.": "Έχετε φτάσει το ημερήσιο όριο χαρακτήρων μετάφρασης. Επιλέξτε άλλη υπηρεσία μετάφρασης για να συνεχίσετε.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} φωνή",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} φωνές",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Κάτι πήγε στραβά. Μην ανησυχείτε, η ομάδα μας έχει ενημερωθεί και εργαζόμαστε για μια λύση.",
"Error Details:": "Λεπτομέρειες σφάλματος:",
"Try Again": "Δοκιμάστε ξανά",
"Need help?": "Χρειάζεστε βοήθεια;",
"Contact Support": "Επικοινωνήστε με την υποστήριξη",
"Code Highlighting": "Επισήμανση κώδικα",
"Enable Highlighting": "Ενεργοποίηση επισήμανσης",
"Code Language": "Γλώσσα κώδικα",
"Top Margin (px)": "Πάνω περιθώριο (px)",
"Bottom Margin (px)": "Κάτω περιθώριο (px)",
"Right Margin (px)": "Δεξί περιθώριο (px)",
"Left Margin (px)": "Αριστερό περιθώριο (px)",
"Column Gap (%)": "Απόσταση στηλών (%)",
"Always Show Status Bar": "Πάντα εμφάνιση γραμμής κατάστασης",
"Translation Not Available": "Η μετάφραση δεν είναι διαθέσιμη",
"Custom Content CSS": "Προσαρμοσμένο CSS περιεχομένου",
"Enter CSS for book content styling...": "Εισαγάγετε CSS για στυλ περιεχομένου βιβλίου...",
"Custom Reader UI CSS": "Προσαρμοσμένο CSS διεπαφής αναγνώστη",
"Enter CSS for reader interface styling...": "Εισαγάγετε CSS για στυλ διεπαφής αναγνώστη...",
"Crop": "Περικοπή",
"Book Covers": "Εξώφυλλα βιβλίων",
"Fit": "Προσαρμογή",
"Reset {{settings}}": "Επαναφορά {{settings}}",
"Reset Settings": "Επαναφορά ρυθμίσεων",
"{{count}} pages left in chapter_one": "Μένει {{count}} σελίδα",
"{{count}} pages left in chapter_other": "Μένουν {{count}} σελίδες",
"Show Remaining Pages": "Εμφάνιση υπολοίπων"
}
@@ -1,4 +1,10 @@
{
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC"
"LXGW WenKai TC": "LXGW WenKai TC",
"Are you sure to delete {{count}} selected book(s)?_one": "Are you sure to delete {{count}} selected book?",
"Are you sure to delete {{count}} selected book(s)?_other": "Are you sure to delete {{count}} selected books?",
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
"Search in {{count}} Book(s)..._other": "Search in {{count}} books...",
"{{count}} pages left in chapter_one": "{{count}} page left in chapter",
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Confirmar eliminación",
"Copied to notebook": "Copiado al cuaderno",
"Copy": "Copiar",
"Custom CSS": "CSS personalizado",
"Dark Mode": "Modo oscuro",
"Default": "Predeterminado",
"Default Font": "Fuente predeterminada",
@@ -28,7 +27,6 @@
"Dictionary": "Diccionario",
"Download Readest": "Descargar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Introduce tu CSS personalizado aquí...",
"Excerpts": "Extractos",
"Failed to import book(s): {{filenames}}": "Error al importar libro(s): {{filenames}}",
"Fast": "Rápido",
@@ -114,8 +112,6 @@
"Your Library": "Tu biblioteca",
"TTS not supported for PDF": "TTS no es compatible con PDF",
"Override Book Font": "Sobrescribir fuente del libro",
"Vertical Margins (px)": "Márgenes verticales (px)",
"Horizontal Margins (%)": "Márgenes horizontales (%)",
"Apply to All Books": "Aplicar a todos los libros",
"Apply to This Book": "Aplicar a este libro",
"Unable to fetch the translation. Try again later.": "No se puede obtener la traducción. Inténtalo de nuevo más tarde.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Iniciar sesión con Apple",
"Sign in with GitHub": "Iniciar sesión con GitHub",
"Account": "Cuenta",
"Cloud Storage": "Almacenamiento en la nube",
"Failed to delete user. Please try again later.": "Error al eliminar usuario. Por favor, inténtelo de nuevo más tarde.",
"Unlimited Offline/Online Reading": "Lectura sin límites en línea/sin conexión",
"Unlimited Cloud Sync Devices": "Sincronización ilimitada de dispositivos en la nube",
@@ -267,7 +262,7 @@
"Clear Search": "Limpiar búsqueda",
"Header & Footer": "Encabezado y pie de página",
"Apply also in Scrolled Mode": "Aplicar también en modo desplazamiento",
"A new version of Readest is Available!": "¡Una nueva versión de Readest está disponible!",
"A new version of Readest is available!": "¡Una nueva versión de Readest está disponible!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} está disponible (versión instalada {{currentVersion}}).",
"Download and install now?": "Descargar e instalar ahora?",
"Downloading {{downloaded}} of {{contentLength}}": "Descargando {{downloaded}} de {{contentLength}}",
@@ -331,5 +326,57 @@
"Scroll": "Desplazar",
"Overlap Pixels": "Superponer píxeles",
"Click": "Click",
"System Language": "Idioma del sistema"
"System Language": "Idioma del sistema",
"Security": "Seguridad",
"Allow JavaScript": "Permitir JavaScript",
"Enable only if you trust the file.": "Permitir solo si confías en el archivo.",
"Sort TOC by Page": "Ordenar TOC por página",
"Search in {{count}} Book(s)..._one": "Buscar en {{count}} libro...",
"Search in {{count}} Book(s)..._many": "Buscar en {{count}} libros...",
"Search in {{count}} Book(s)..._other": "Buscar en {{count}} libros...",
"No notes match your search": "No se encontraron notas",
"Search notes and excerpts...": "Buscar notas...",
"Sign in to Sync": "Inicia sesión para sincronizar",
"Synced at {{time}}": "Sincronizado a las {{time}}",
"Never synced": "Nunca sincronizado",
"Show Remaining Time": "Mostrar tiempo restante",
"Show Page Number": "Mostrar número de página",
"{{time}} min left in chapter": "{{time}} min restantes en el capítulo",
"Override Book Color": "Sobre escribir color del libro",
"Login Required": "Iniciar sesión",
"Quota Exceeded": "Límite superado",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% de caracteres de traducción diarios utilizados.",
"Translation Characters": "Caracteres de traducción",
"Daily translation quota reached. Select another translate service to proceed.": "Cuota diaria de traducción alcanzada. Selecciona otro servicio de traducción para continuar.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voz",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} voces",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} voces",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Algo salió mal. No te preocupes, nuestro equipo ha sido notificado y estamos trabajando en una solución.",
"Error Details:": "Detalles del error:",
"Try Again": "Intentar de nuevo",
"Need help?": "¿Necesitas ayuda?",
"Contact Support": "Contactar con soporte",
"Code Highlighting": "Resaltado de código",
"Enable Highlighting": "Activar resaltado",
"Code Language": "Lenguaje de código",
"Top Margin (px)": "Margen superior (px)",
"Bottom Margin (px)": "Margen inferior (px)",
"Right Margin (px)": "Margen derecho (px)",
"Left Margin (px)": "Margen izquierdo (px)",
"Column Gap (%)": "Espacio entre columnas (%)",
"Always Show Status Bar": "Mostrar siempre la barra de estado",
"Translation Not Available": "Traducción no disponible",
"Custom Content CSS": "CSS del contenido",
"Enter CSS for book content styling...": "Introduce CSS para el contenido del libro...",
"Custom Reader UI CSS": "CSS de la interfaz",
"Enter CSS for reader interface styling...": "Introduce CSS para la interfaz de lectura...",
"Crop": "Recortar",
"Book Covers": "Portadas de libros",
"Fit": "Encajar",
"Reset {{settings}}": "Restablecer {{settings}}",
"Reset Settings": "Restablecer configuración",
"{{count}} pages left in chapter_one": "{{count}} página restante en el capítulo",
"{{count}} pages left in chapter_many": "{{count}} páginas restantes en el capítulo",
"{{count}} pages left in chapter_other": "{{count}} páginas restantes en el capítulo",
"Show Remaining Pages": "Mostrar resto"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Confirmer la suppression",
"Copied to notebook": "Copié dans le carnet de notes",
"Copy": "Copier",
"Custom CSS": "CSS personnalisé",
"Dark Mode": "Mode sombre",
"Default": "Par défaut",
"Default Font": "Police par défaut",
@@ -28,7 +27,6 @@
"Dictionary": "Dictionnaire",
"Download Readest": "Télécharger Readest",
"Edit": "Modifier",
"Enter your custom CSS here...": "Saisissez votre CSS personnalisé ici...",
"Excerpts": "Extraits",
"Failed to import book(s): {{filenames}}": "Échec de l'importation du/des livre(s) : {{filenames}}",
"Fast": "Rapide",
@@ -114,8 +112,6 @@
"Your Library": "Votre bibliothèque",
"TTS not supported for PDF": "La synthèse vocale n'est pas prise en charge pour les fichiers PDF",
"Override Book Font": "Remplacer la police du livre",
"Vertical Margins (px)": "Marges verticales (px)",
"Horizontal Margins (%)": "Marges horizontales (%)",
"Apply to All Books": "Appliquer à tous les livres",
"Apply to This Book": "Appliquer à ce livre",
"Unable to fetch the translation. Try again later.": "Impossible de récupérer la traduction. Réessayez plus tard.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Se connecter avec Apple",
"Sign in with GitHub": "Se connecter avec GitHub",
"Account": "Compte",
"Cloud Storage": "Stockage cloud",
"Failed to delete user. Please try again later.": "Échec de la suppression de l'utilisateur. Veuillez réessayer plus tard.",
"Unlimited Offline/Online Reading": "Lecture illimitée hors ligne/en ligne",
"Unlimited Cloud Sync Devices": "Synchronisation cloud sur appareils illimitée",
@@ -267,7 +262,7 @@
"Clear Search": "Effacer la recherche",
"Header & Footer": "En-tête et pied de page",
"Apply also in Scrolled Mode": "Appliquer également en mode défilement",
"A new version of Readest is Available!": "Avis de mise à jour",
"A new version of Readest is available!": "Avis de mise à jour",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} est disponible (version installée {{currentVersion}}).",
"Download and install now?": "Télécharger et installer maintenant ?",
"Downloading {{downloaded}} of {{contentLength}}": "Téléchargement {{downloaded}} sur {{contentLength}}",
@@ -331,5 +326,57 @@
"Scroll": "Défilement",
"Overlap Pixels": "Pixels de chevauchement",
"Click": "Cliquer",
"System Language": "Langue du système"
"System Language": "Langue du système",
"Security": "Sécurité",
"Allow JavaScript": "Autoriser JavaScript",
"Enable only if you trust the file.": "Activer uniquement si vous faites confiance au fichier.",
"Sort TOC by Page": "Tris la table des matières par page",
"Search in {{count}} Book(s)..._one": "Rechercher dans {{count}} Livre...",
"Search in {{count}} Book(s)..._many": "Rechercher dans {{count}} Livres...",
"Search in {{count}} Book(s)..._other": "Rechercher dans {{count}} Livres...",
"No notes match your search": "Aucune note trouvée",
"Search notes and excerpts...": "Rechercher des notes...",
"Sign in to Sync": "Connectez-vous pour synchroniser",
"Synced at {{time}}": "Synchronisé à {{time}}",
"Never synced": "Jamais synchronisé",
"Show Remaining Time": "Afficher le temps restant",
"Show Page Number": "Afficher le numéro de page",
"{{time}} min left in chapter": "{{time}} min restant dans le chapitre",
"Override Book Color": "Remplacer la couleur du livre",
"Login Required": "Connexion requise",
"Quota Exceeded": "Quota dépassé",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% des caractères de traduction quotidiens utilisés.",
"Translation Characters": "Caractères de traduction",
"Daily translation quota reached. Select another translate service to proceed.": "Quota de traduction quotidien atteint. Sélectionnez un autre service de traduction pour continuer.",
"{{engine}}: {{count}} voices_one": "{{engine}} : {{count}} voix",
"{{engine}}: {{count}} voices_many": "{{engine}} : {{count}} voix",
"{{engine}}: {{count}} voices_other": "{{engine}} : {{count}} voix",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Quelque chose s'est mal passé. Ne vous inquiétez pas, notre équipe a été informée et nous travaillons sur une solution.",
"Error Details:": "Erreur :",
"Try Again": "Réessayer",
"Need help?": "Besoin d'aide ?",
"Contact Support": "Contacter le support",
"Code Highlighting": "Coloration syntaxique",
"Enable Highlighting": "Activer la coloration syntaxique",
"Code Language": "Langage de programmation",
"Top Margin (px)": "Marge supérieure (px)",
"Bottom Margin (px)": "Marge inférieure (px)",
"Right Margin (px)": "Marge droite (px)",
"Left Margin (px)": "Marge gauche (px)",
"Column Gap (%)": "Espacement des colonnes (%)",
"Always Show Status Bar": "Afficher toujours la barre d'état",
"Translation Not Available": "Traduction non disponible",
"Custom Content CSS": "CSS du contenu",
"Enter CSS for book content styling...": "Saisissez le CSS pour le contenu du livre...",
"Custom Reader UI CSS": "CSS de linterface",
"Enter CSS for reader interface styling...": "Saisissez le CSS pour linterface de lecture...",
"Crop": "Rogner",
"Book Covers": "Couvertures de livres",
"Fit": "Adapter",
"Reset {{settings}}": "Réinitialiser {{settings}}",
"Reset Settings": "Réinitialiser les paramètres",
"{{count}} pages left in chapter_one": "{{count}} page restant dans le chapitre",
"{{count}} pages left in chapter_many": "{{count}} pages restantes dans le chapitre",
"{{count}} pages left in chapter_other": "{{count}} pages restantes dans le chapitre",
"Show Remaining Pages": "Voir restantes"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "हटाने की पुष्टि करें",
"Copied to notebook": "नोटबुक में कॉपी किया गया",
"Copy": "कॉपी",
"Custom CSS": "कस्टम CSS",
"Dark Mode": "डार्क मोड",
"Default": "डिफ़ॉल्ट",
"Default Font": "डिफ़ॉल्ट फ़ॉन्ट",
@@ -28,7 +27,6 @@
"Dictionary": "शब्दकोश",
"Download Readest": "Readest डाउनलोड करें",
"Edit": "संपादित करें",
"Enter your custom CSS here...": "अपना कस्टम CSS यहां दर्ज करें...",
"Excerpts": "अंश",
"Failed to import book(s): {{filenames}}": "पुस्तक(ें) आयात करने में विफल: {{filenames}}",
"Fast": "तेज़",
@@ -114,8 +112,6 @@
"Your Library": "आपकी लाइब्रेरी",
"TTS not supported for PDF": "PDF के लिए TTS समर्थित नहीं है",
"Override Book Font": "पुस्तक फ़ॉन्ट ओवरराइड करें",
"Vertical Margins (px)": "ऊर्ध्वाधर मार्जिन (px)",
"Horizontal Margins (%)": "क्षैतिज मार्जिन (%)",
"Apply to All Books": "सभी पुस्तकों पर लागू करें",
"Apply to This Book": "इस पुस्तक पर लागू करें",
"Unable to fetch the translation. Try again later.": "अनुवाद प्राप्त करने में असमर्थ। बाद में पुनः प्रयास करें।",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Apple के साथ साइन इन करें",
"Sign in with GitHub": "GitHub के साथ साइन इन करें",
"Account": "खाता",
"Cloud Storage": "क्लाउड स्टोरेज",
"Failed to delete user. Please try again later.": "उपयोगकर्ता को हटाने में विफल। कृपया बाद में पुनः प्रयास करें।",
"Unlimited Offline/Online Reading": "असीमित ऑफलाइन/ऑनलाइन पढ़ना",
"Unlimited Cloud Sync Devices": "असीमित क्लाउड सिंक उपकरण",
@@ -267,7 +262,7 @@
"Clear Search": "खोज साफ करें",
"Header & Footer": "हेडर और फ़ुटर",
"Apply also in Scrolled Mode": "स्क्रॉल मोड में भी लागू करें",
"A new version of Readest is Available!": "Readest का एक नया संस्करण उपलब्ध है!",
"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}}": "डाउनलोड कर रहा है {{downloaded}} का {{contentLength}}",
@@ -330,5 +325,54 @@
"Scroll": "स्क्रॉल",
"Overlap Pixels": "ओवरलैप पिक्सेल",
"Click": "क्लिक करें",
"System Language": "सिस्टम भाषा"
"System Language": "सिस्टम भाषा",
"Security": "सुरक्षा",
"Allow JavaScript": "जावास्क्रिप्ट की अनुमति दें",
"Enable only if you trust the file.": "केवल तभी सक्षम करें जब आप फ़ाइल पर भरोसा करते हों।",
"Sort TOC by Page": "सामग्री को पृष्ठ द्वारा क्रमबद्ध करें",
"Search in {{count}} Book(s)..._one": "{{count}} पुस्तक में खोजें...",
"Search in {{count}} Book(s)..._other": "{{count}} पुस्तकों में खोजें...",
"No notes match your search": "कोई नोट्स नहीं मिले",
"Search notes and excerpts...": "नोट्स खोजें...",
"Sign in to Sync": "सिंक करने के लिए साइन इन करें",
"Synced at {{time}}": "{{time}} पर सिंक किया गया",
"Never synced": "कभी सिंक नहीं किया गया",
"Show Remaining Time": "शेष समय दिखाएं",
"Show Page Number": "पृष्ठ संख्या दिखाएं",
"{{time}} min left in chapter": "{{time}} मिनट शेष अध्याय में",
"Override Book Color": "पुस्तक रंग ओवरराइड करें",
"Login Required": "लॉगिन ज़रूरी",
"Quota Exceeded": "सीमा पार",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% दैनिक अनुवाद वर्णों का उपयोग किया गया है।",
"Translation Characters": "अनुवाद वर्ण",
"Daily translation quota reached. Select another translate service to proceed.": "दैनिक अनुवाद कोटा पूरा हो गया है। आगे बढ़ने के लिए कोई अन्य अनुवाद सेवा चुनें।",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} आवाज़",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} आवाज़ें",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "कुछ गलत हो गया। चिंता न करें, हमारी टीम को सूचित कर दिया गया है और हम एक समाधान पर काम कर रहे हैं।",
"Error Details:": "त्रुटि विवरण:",
"Try Again": "फिर से प्रयास करें",
"Need help?": "मदद चाहिए?",
"Contact Support": "सहायता से संपर्क करें",
"Code Highlighting": "कोड हाइलाइटिंग",
"Enable Highlighting": "हाइलाइटिंग सक्षम करें",
"Code Language": "कोड भाषा",
"Top Margin (px)": "ऊपरी हाशिया (px)",
"Bottom Margin (px)": "निचला हाशिया (px)",
"Right Margin (px)": "दायां हाशिया (px)",
"Left Margin (px)": "बायां हाशिया (px)",
"Column Gap (%)": "स्तंभों के बीच की दूरी (%)",
"Always Show Status Bar": "स्थिति पट्टी हमेशा दिखाएं",
"Translation Not Available": "अनुवाद उपलब्ध नहीं है",
"Custom Content CSS": "सामग्री का CSS",
"Enter CSS for book content styling...": "पुस्तक सामग्री के लिए CSS दर्ज करें...",
"Custom Reader UI CSS": "इंटरफ़ेस का CSS",
"Enter CSS for reader interface styling...": "रीडर इंटरफ़ेस के लिए CSS दर्ज करें...",
"Crop": "काटें",
"Book Covers": "पुस्तक कवर",
"Fit": "फिट",
"Reset {{settings}}": "रीसेट {{settings}}",
"Reset Settings": "सेटिंग्स रीसेट करें",
"{{count}} pages left in chapter_one": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
"{{count}} pages left in chapter_other": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
"Show Remaining Pages": "शेष पृष्ठ दिखाएँ"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Konfirmasi Penghapusan",
"Copied to notebook": "Disalin ke buku catatan",
"Copy": "Salin",
"Custom CSS": "CSS Kustom",
"Dark Mode": "Mode Gelap",
"Default": "Default",
"Default Font": "Font Default",
@@ -28,7 +27,6 @@
"Dictionary": "Kamus",
"Download Readest": "Unduh Readest",
"Edit": "Edit",
"Enter your custom CSS here...": "Masukkan CSS kustom Anda di sini...",
"Excerpts": "Kutipan",
"Failed to import book(s): {{filenames}}": "Gagal mengimpor buku: {{filenames}}",
"Fast": "Cepat",
@@ -114,8 +112,6 @@
"Your Library": "Perpustakaan Anda",
"TTS not supported for PDF": "TTS tidak didukung untuk PDF",
"Override Book Font": "Ganti Font Buku",
"Vertical Margins (px)": "Margin Vertikal (px)",
"Horizontal Margins (%)": "Margin Horizontal (%)",
"Apply to All Books": "Terapkan ke semua buku",
"Apply to This Book": "Terapkan ke buku ini",
"Unable to fetch the translation. Try again later.": "Tidak dapat mengambil terjemahan. Coba lagi nanti.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Masuk dengan Apple",
"Sign in with GitHub": "Masuk dengan GitHub",
"Account": "Akun",
"Cloud Storage": "Penyimpanan Cloud",
"Failed to delete user. Please try again later.": "Gagal menghapus pengguna. Silakan coba lagi nanti.",
"Unlimited Offline/Online Reading": "Membaca Offline/Online Tanpa Batas",
"Unlimited Cloud Sync Devices": "Perangkat Sinkronisasi Cloud Tanpa Batas",
@@ -267,7 +262,7 @@
"Clear Search": "Hapus Pencarian",
"Header & Footer": "Header & Footer",
"Apply also in Scrolled Mode": "Terapkan juga di Mode Gulir",
"A new version of Readest is Available!": "Versi baru Readest Tersedia!",
"A new version of Readest is available!": "Versi baru Readest Tersedia!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} tersedia (versi terinstal {{currentVersion}}).",
"Download and install now?": "Unduh dan instal sekarang?",
"Downloading {{downloaded}} of {{contentLength}}": "Mengunduh {{downloaded}} dari {{contentLength}}",
@@ -329,5 +324,51 @@
"Scroll": "Scroll",
"Overlap Pixels": "Overlap Piksel",
"Click": "Klik",
"System Language": "Bahasa Sistem"
"System Language": "Bahasa Sistem",
"Security": "Keamanan",
"Allow JavaScript": "Izinkan JavaScript",
"Enable only if you trust the file.": "Aktifkan hanya jika Anda mempercayai file tersebut.",
"Sort TOC by Page": "Urutkan TOC berdasarkan Halaman",
"Search in {{count}} Book(s)..._other": "Cari di {{count}} Buku...",
"No notes match your search": "Tidak ada catatan ditemukan",
"Search notes and excerpts...": "Cari catatan...",
"Sign in to Sync": "Masuk untuk Sinkronisasi",
"Synced at {{time}}": "Disinkronkan pada {{time}}",
"Never synced": "Belum pernah disinkronkan",
"Show Remaining Time": "Perlihatkan Waktu Tersisa",
"Show Page Number": "Perlihatkan Nomor Halaman",
"{{time}} min left in chapter": "{{time}} menit tersisa di bab",
"Override Book Color": "Ubah Warna Buku",
"Login Required": "Perlu masuk",
"Quota Exceeded": "Kuota terlampaui",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dari Karakter Terjemahan Harian Digunakan.",
"Translation Characters": "Karakter Terjemahan",
"Daily translation quota reached. Select another translate service to proceed.": "Kuota terjemahan harian tercapai. Pilih layanan terjemahan lain untuk melanjutkan.",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} suara",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Terjadi kesalahan. Jangan khawatir, tim kami telah diberitahu dan kami sedang bekerja untuk memperbaikinya.",
"Error Details:": "Rincian Kesalahan:",
"Try Again": "Coba Lagi",
"Need help?": "Butuh bantuan?",
"Contact Support": "Hubungi Dukungan",
"Code Highlighting": "Penyorotan Kode",
"Enable Highlighting": "Aktifkan Penyorotan",
"Code Language": "Bahasa Kode",
"Top Margin (px)": "Margin Atas (px)",
"Bottom Margin (px)": "Margin Bawah (px)",
"Right Margin (px)": "Margin Kanan (px)",
"Left Margin (px)": "Margin Kiri (px)",
"Column Gap (%)": "Jarak Antar Kolom (%)",
"Always Show Status Bar": "Selalu Tampilkan Bilah Status",
"Translation Not Available": "Terjemahan Tidak Tersedia",
"Custom Content CSS": "CSS konten",
"Enter CSS for book content styling...": "Masukkan CSS untuk gaya konten buku...",
"Custom Reader UI CSS": "CSS antarmuka",
"Enter CSS for reader interface styling...": "Masukkan CSS untuk gaya antarmuka pembaca...",
"Crop": "Potong",
"Book Covers": "Sampul Buku",
"Fit": "Pas",
"Reset {{settings}}": "Atur Ulang {{settings}}",
"Reset Settings": "Atur Ulang Pengaturan",
"{{count}} pages left in chapter_other": "{{count}} halaman tersisa di bab ini",
"Show Remaining Pages": "Tampilkan halaman tersisa"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Conferma eliminazione",
"Copied to notebook": "Copiato nel quaderno",
"Copy": "Copia",
"Custom CSS": "CSS personalizzato",
"Dark Mode": "Modalità scura",
"Default": "Predefinito",
"Default Font": "Font predefinito",
@@ -28,7 +27,6 @@
"Dictionary": "Dizionario",
"Download Readest": "Scarica Readest",
"Edit": "Modifica",
"Enter your custom CSS here...": "Inserisci qui il tuo CSS personalizzato...",
"Excerpts": "Estratti",
"Failed to import book(s): {{filenames}}": "Impossibile importare il/i libro/i: {{filenames}}",
"Fast": "Veloce",
@@ -114,8 +112,6 @@
"Your Library": "La tua biblioteca",
"TTS not supported for PDF": "TTS non supportato per PDF",
"Override Book Font": "Sovrascrivi font libro",
"Vertical Margins (px)": "Margini verticali (px)",
"Horizontal Margins (%)": "Margini orizzontali (%)",
"Apply to All Books": "Applica a tutti i libri",
"Apply to This Book": "Applica a questo libro",
"Unable to fetch the translation. Try again later.": "Impossibile recuperare la traduzione. Riprova più tardi.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Accedi con Apple",
"Sign in with GitHub": "Accedi con GitHub",
"Account": "Account",
"Cloud Storage": "Archiviazione Cloud",
"Failed to delete user. Please try again later.": "Impossibile eliminare l'utente. Riprova più tardi.",
"Unlimited Offline/Online Reading": "Lettura offline/online illimitata",
"Unlimited Cloud Sync Devices": "Sincronizzazione cloud su dispositivi illimitati",
@@ -267,7 +262,7 @@
"Clear Search": "Cancella ricerca",
"Header & Footer": "Intestazione e piè di pagina",
"Apply also in Scrolled Mode": "Applica anche in modalità scorrimento",
"A new version of Readest is Available!": "È disponibile una nuova versione di Readest!",
"A new version of Readest is available!": "È disponibile una nuova versione di Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} è disponibile (versione installata {{currentVersion}}).",
"Download and install now?": "Scarica e installa ora?",
"Downloading {{downloaded}} of {{contentLength}}": "Scaricando {{downloaded}} di {{contentLength}}",
@@ -331,5 +326,57 @@
"Scroll": "Scorri",
"Overlap Pixels": "Pixel di sovrapposizione",
"Click": "Fai clic",
"System Language": "Lingua di sistema"
"System Language": "Lingua di sistema",
"Security": "Security",
"Allow JavaScript": "Consenti JavaScript",
"Enable only if you trust the file.": "Abilita solo se ti fidi del file.",
"Sort TOC by Page": "Ordina sommario per pagina",
"Search in {{count}} Book(s)..._one": "Cerca in {{count}} libro...",
"Search in {{count}} Book(s)..._many": "Cerca in {{count}} libri...",
"Search in {{count}} Book(s)..._other": "Cerca in {{count}} libri...",
"No notes match your search": "Nessuna nota trovata",
"Search notes and excerpts...": "Cerca nelle note...",
"Sign in to Sync": "Accedi per sincronizzare",
"Synced at {{time}}": "Sincronizzato alle {{time}}",
"Never synced": "Mai sincronizzato",
"Show Remaining Time": "Mostra tempo rimanente",
"Show Page Number": "Mostra numero pagina",
"{{time}} min left in chapter": "{{time}} min rimasti nel capitolo",
"Override Book Color": "Override colore libro",
"Login Required": "Accesso richiesto",
"Quota Exceeded": "Limite superato",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dei caratteri di traduzione giornalieri utilizzati.",
"Translation Characters": "Caratteri di traduzione",
"Daily translation quota reached. Select another translate service to proceed.": "Quota di traduzione giornaliera raggiunta. Seleziona un altro servizio di traduzione per procedere.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voce",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} voci",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} voci",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Qualcosa è andato storto. Non preoccuparti, il nostro team è stato avvisato e stiamo lavorando a una soluzione.",
"Error Details:": "Dettagli errore:",
"Try Again": "Riprova",
"Need help?": "Hai bisogno di aiuto?",
"Contact Support": "Contatta il supporto",
"Code Highlighting": "Evidenziazione codice",
"Enable Highlighting": "Abilita evidenziazione",
"Code Language": "Tipo di codice",
"Top Margin (px)": "Margine Superiore (px)",
"Bottom Margin (px)": "Margine Inferiore (px)",
"Right Margin (px)": "Margine Destro (px)",
"Left Margin (px)": "Margine Sinistro (px)",
"Column Gap (%)": "Spazio tra Colonne (%)",
"Always Show Status Bar": "Mostra sempre la barra di stato",
"Translation Not Available": "Traduzione non disponibile",
"Custom Content CSS": "CSS contenuto",
"Enter CSS for book content styling...": "Inserisci il CSS per il contenuto del libro...",
"Custom Reader UI CSS": "CSS interfaccia",
"Enter CSS for reader interface styling...": "Inserisci il CSS per l'interfaccia di lettura...",
"Crop": "Ritaglia",
"Book Covers": "Copertine libri",
"Fit": "Adatta",
"Reset {{settings}}": "Reimposta {{settings}}",
"Reset Settings": "Reimposta impostazioni",
"{{count}} pages left in chapter_one": "{{count}} pagina rimasta nel capitolo",
"{{count}} pages left in chapter_many": "{{count}} pagine rimaste nel capitolo",
"{{count}} pages left in chapter_other": "{{count}} pagine rimaste nel capitolo",
"Show Remaining Pages": "Mostra pagine rimanenti"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "削除の確認",
"Copied to notebook": "ノートブックにコピー",
"Copy": "コピー",
"Custom CSS": "カスタムCSS",
"Dark Mode": "ダークモード",
"Default": "デフォルト",
"Default Font": "デフォルトフォント",
@@ -28,7 +27,6 @@
"Dictionary": "辞書",
"Download Readest": "Readestをダウンロード",
"Edit": "編集",
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
"Excerpts": "抜粋",
"Failed to import book(s): {{filenames}}": "書籍のインポートに失敗しました:{{filenames}}",
"Fast": "高速",
@@ -114,8 +112,6 @@
"Your Library": "ライブラリー",
"TTS not supported for PDF": "PDFではTTSがサポートされていません",
"Override Book Font": "書籍フォントを上書き",
"Vertical Margins (px)": "縦マージン(px",
"Horizontal Margins (%)": "横マージン(%",
"Apply to All Books": "すべての書籍に適用",
"Apply to This Book": "この書籍に適用",
"Unable to fetch the translation. Try again later.": "翻訳を取得できません。後で再試行してください。",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Appleでサインイン",
"Sign in with GitHub": "GitHubでサインイン",
"Account": "アカウント",
"Cloud Storage": "クラウドストレージ",
"Failed to delete user. Please try again later.": "ユーザーの削除に失敗しました。後ほど再試行してください。",
"Unlimited Offline/Online Reading": "無制限のオフライン/オンライン読書",
"Unlimited Cloud Sync Devices": "無制限のクラウド同期デバイス",
@@ -267,7 +262,7 @@
"Clear Search": "検索をクリア",
"Header & Footer": "ヘッダーとフッター",
"Apply also in Scrolled Mode": "スクロールモードでも適用",
"A new version of Readest is Available!": "新しいバージョンのReadestが利用可能です!",
"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}}": "ダウンロード中 {{downloaded}} / {{contentLength}}",
@@ -329,5 +324,51 @@
"Scroll": "スクロール",
"Overlap Pixels": "重なりピクセル",
"Click": "クリック",
"System Language": "システム言語"
"System Language": "システム言語",
"Security": "セキュリティ",
"Allow JavaScript": "JavaScriptを許可",
"Enable only if you trust the file.": "ファイルを信頼する場合にのみ有効にしてください。",
"Sort TOC by Page": "目次をページで並べ替え",
"Search in {{count}} Book(s)..._other": "{{count}}冊の本を検索...",
"No notes match your search": "ノートが見つかりません",
"Search notes and excerpts...": "ノートを検索...",
"Sign in to Sync": "同期するにはサインイン",
"Synced at {{time}}": "{{time}} に同期済み",
"Never synced": "まだ同期されていません",
"Show Remaining Time": "残り時間を表示",
"Show Page Number": "ページ番号を表示",
"{{time}} min left in chapter": "{{time}}分残り",
"Override Book Color": "書籍の色を上書き",
"Login Required": "ログイン必須",
"Quota Exceeded": "上限超過",
"{{percentage}}% of Daily Translation Characters Used.": "日次翻訳文字数の使用量は{{percentage}}%です。",
"Translation Characters": "翻訳文字数",
"Daily translation quota reached. Select another translate service to proceed.": "日次翻訳クォータに達しました。続行するには別の翻訳サービスを選択してください。",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}音声",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "何か問題が発生しました。心配しないでください。私たちのチームが通知を受けており、修正に取り組んでいます。",
"Error Details:": "エラーの詳細:",
"Try Again": "再試行",
"Need help?": "ヘルプが必要ですか?",
"Contact Support": "サポートに連絡",
"Code Highlighting": "コードハイライト",
"Enable Highlighting": "ハイライトを有効にする",
"Code Language": "コード言語",
"Top Margin (px)": "上マージン (px)",
"Bottom Margin (px)": "下マージン (px)",
"Right Margin (px)": "右マージン (px)",
"Left Margin (px)": "左マージン (px)",
"Column Gap (%)": "列間の間隔 (%)",
"Always Show Status Bar": "ステータスバーを常に表示",
"Translation Not Available": "翻訳は利用できません",
"Custom Content CSS": "コンテンツCSS",
"Enter CSS for book content styling...": "本のコンテンツ用CSSを入力...",
"Custom Reader UI CSS": "リーダーUIのCSS",
"Enter CSS for reader interface styling...": "リーダーインターフェース用CSSを入力...",
"Crop": "トリミング",
"Book Covers": "書籍の表紙",
"Fit": "フィット",
"Reset {{settings}}": "{{settings}}をリセット",
"Reset Settings": "設定をリセット",
"{{count}} pages left in chapter_other": "{{count}} ページ残り",
"Show Remaining Pages": "残りを表示"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "삭제 확인",
"Copied to notebook": "노트북에 복사됨",
"Copy": "복사",
"Custom CSS": "사용자 정의 CSS",
"Dark Mode": "다크 모드",
"Default": "기본값",
"Default Font": "기본 글꼴",
@@ -28,7 +27,6 @@
"Dictionary": "사전",
"Download Readest": "Readest 다운로드",
"Edit": "편집",
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
"Excerpts": "발췌",
"Failed to import book(s): {{filenames}}": "책 가져오기 실패: {{filenames}}",
"Fast": "빠름",
@@ -114,8 +112,6 @@
"Your Library": "내 서재",
"TTS not supported for PDF": "PDF에서 TTS가 지원되지 않음",
"Override Book Font": "책 글꼴 덮어쓰기",
"Vertical Margins (px)": "수직 여백 (px)",
"Horizontal Margins (%)": "수평 여백 (%)",
"Apply to All Books": "모든 책에 적용",
"Apply to This Book": "이 책에 적용",
"Unable to fetch the translation. Try again later.": "번역을 가져올 수 없습니다. 나중에 다시 시도하세요.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Apple로 로그인",
"Sign in with GitHub": "GitHub로 로그인",
"Account": "계정",
"Cloud Storage": "클라우드 스토리지",
"Failed to delete user. Please try again later.": "사용자 삭제에 실패했습니다. 나중에 다시 시도해 주세요.",
"Unlimited Offline/Online Reading": "무제한 오프라인/온라인 읽기",
"Unlimited Cloud Sync Devices": "무제한 클라우드 동기화 기기",
@@ -267,7 +262,7 @@
"Clear Search": "검색 지우기",
"Header & Footer": "헤더 및 푸터",
"Apply also in Scrolled Mode": "스크롤 모드에서도 적용",
"A new version of Readest is Available!": "Readest의 새 버전이 있습니다!",
"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}}": "다운로드 중 {{downloaded}} / {{contentLength}}",
@@ -329,5 +324,51 @@
"Scroll": "스크롤",
"Overlap Pixels": "겹치는 픽셀",
"Click": "클릭",
"System Language": "시스템 언어"
"System Language": "시스템 언어",
"Security": "보안",
"Allow JavaScript": "JavaScript 허용",
"Enable only if you trust the file.": "파일을 신뢰하는 경우에만 활성화하세요.",
"Sort TOC by Page": "목차를 페이지별로 정렬",
"Search in {{count}} Book(s)..._other": "{{count}}권의 책에서 검색...",
"No notes match your search": "일치하는 노트가 없습니다",
"Search notes and excerpts...": "노트 검색...",
"Sign in to Sync": "동기화를 위해 로그인하세요",
"Synced at {{time}}": "{{time}}에 동기화됨",
"Never synced": "동기화된 적 없음",
"Show Remaining Time": "남은 시간 표시",
"Show Page Number": "페이지 번호 표시",
"{{time}} min left in chapter": "{{time}}분 남음",
"Override Book Color": "책 색상 덮어쓰기",
"Login Required": "로그인 필요",
"Quota Exceeded": "한도 초과",
"{{percentage}}% of Daily Translation Characters Used.": "일일 번역 문자 사용량 {{percentage}}%.",
"Translation Characters": "번역 문자",
"Daily translation quota reached. Select another translate service to proceed.": "일일 번역 할당량에 도달했습니다. 계속 진행하려면 다른 번역 서비스를 선택하세요.",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}}개의 음성",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "문제가 발생했습니다. 걱정하지 마세요, 저희 팀이 이미 알림을 받았으며 해결 작업을 진행 중입니다.",
"Error Details:": "오류 세부사항:",
"Try Again": "다시 시도",
"Need help?": "도움이 필요하신가요?",
"Contact Support": "지원팀에 문의",
"Code Highlighting": "코드 하이라이팅",
"Enable Highlighting": "하이라이팅 활성화",
"Code Language": "코드 언어",
"Top Margin (px)": "위쪽 여백 (px)",
"Bottom Margin (px)": "아래쪽 여백 (px)",
"Right Margin (px)": "오른쪽 여백 (px)",
"Left Margin (px)": "왼쪽 여백 (px)",
"Column Gap (%)": "열 간격 (%)",
"Always Show Status Bar": "상태 표시줄 항상 표시",
"Translation Not Available": "번역을 사용할 수 없음",
"Custom Content CSS": "콘텐츠 CSS",
"Enter CSS for book content styling...": "도서 콘텐츠 스타일을 위한 CSS 입력...",
"Custom Reader UI CSS": "리더 UI CSS",
"Enter CSS for reader interface styling...": "리더 인터페이스 스타일용 CSS 입력...",
"Crop": "자르기",
"Book Covers": "책 표지",
"Fit": "맞춤",
"Reset {{settings}}": "{{settings}} 재설정",
"Reset Settings": "설정 재설정",
"{{count}} pages left in chapter_other": "남은 페이지 {{count}}장",
"Show Remaining Pages": "남은 페이지 보기"
}
@@ -169,8 +169,6 @@
"Hyphenation": "Woordafbreking",
"Override Book Layout": "Boekopmaak overschrijven",
"Page": "Pagina",
"Vertical Margins (px)": "Verticale marges (px)",
"Horizontal Margins (%)": "Horizontale marges (%)",
"Maximum Number of Columns": "Maximum aantal kolommen",
"Maximum Column Height": "Maximale kolomhoogte",
"Maximum Column Width": "Maximale kolombreedte",
@@ -191,8 +189,6 @@
"Volume Keys for Page Flip": "Volumetoetsen voor pagina omslaan",
"Clicks for Page Flip": "Klikken voor pagina omslaan",
"Swap Clicks Area": "Klikgebieden omwisselen",
"Custom CSS": "Aangepaste CSS",
"Enter your custom CSS here...": "Voer hier uw aangepaste CSS in...",
"Apply": "Toepassen",
"Font": "Lettertype",
"Layout": "Opmaak",
@@ -228,7 +224,6 @@
"Slow": "Langzaam",
"Fast": "Snel",
"Reading Progress Synced": "Leesvoortgang gesynchroniseerd",
"Cloud Storage": "Cloudopslag",
"Failed to delete user. Please try again later.": "Gebruiker verwijderen mislukt. Probeer het later opnieuw.",
"Free Tier": "Gratis niveau",
"Community Support": "Community-ondersteuning",
@@ -278,7 +273,7 @@
"Description:": "Beschrijving:",
"No description available": "Geen beschrijving beschikbaar",
"Drop to Import Books": "Sleep om boeken te importeren",
"A new version of Readest is Available!": "Een nieuwe versie van Readest is beschikbaar!",
"A new version of Readest is available!": "Een nieuwe versie van Readest is beschikbaar!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} is beschikbaar (geïnstalleerde versie {{currentVersion}}).",
"Download and install now?": "Nu downloaden en installeren?",
"Downloading {{downloaded}} of {{contentLength}}": "Downloaden {{downloaded}} van {{contentLength}}",
@@ -330,5 +325,54 @@
"Scroll": "Scrollen",
"Overlap Pixels": "Overlap pixels",
"Click": "Klik",
"System Language": "Systemtaal"
"System Language": "Systemtaal",
"Security": "Beveiliging",
"Allow JavaScript": "JavaScript toestaan",
"Enable only if you trust the file.": "Schakel alleen in als u het bestand vertrouwt.",
"Sort TOC by Page": "__STRING_NOT_TRANSLATED__",
"Search in {{count}} Book(s)..._one": "Zoeken in {{count}} Boek...",
"Search in {{count}} Book(s)..._other": "Zoeken in {{count}} Boeken...",
"No notes match your search": "Geen notities gevonden",
"Search notes and excerpts...": "Zoek notities...",
"Sign in to Sync": "Meld je aan om te synchroniseren",
"Synced at {{time}}": "Gesynchroniseerd om {{time}}",
"Never synced": "Nooit gesynchroniseerd",
"Show Remaining Time": "Toon resterende tijd",
"Show Page Number": "Toon paginanummer",
"{{time}} min left in chapter": "Nog {{time}} min in hoofdstuk",
"Override Book Color": "Boekkleur overschrijven",
"Login Required": "Inloggen vereist",
"Quota Exceeded": "Limiet overschreden",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% van de dagelijkse vertaaltekens gebruikt.",
"Translation Characters": "Vertaaltekens",
"Daily translation quota reached. Select another translate service to proceed.": "Dagelijkse vertaalquotum bereikt. Selecteer een andere vertaalservice om door te gaan.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} stem",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} stemmen",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Er is iets misgegaan. Maak je geen zorgen, ons team is op de hoogte gebracht en we werken aan een oplossing.",
"Error Details:": "Foutdetails:",
"Try Again": "Probeer opnieuw",
"Need help?": "Heb je hulp nodig?",
"Contact Support": "Neem contact op met ondersteuning",
"Code Highlighting": "Code markering",
"Enable Highlighting": "Markering inschakelen",
"Code Language": "Code taal",
"Top Margin (px)": "Bovenmarge (px)",
"Bottom Margin (px)": "Ondermarge (px)",
"Right Margin (px)": "Rechter marge (px)",
"Left Margin (px)": "Linker marge (px)",
"Column Gap (%)": "Kolomafstand (%)",
"Always Show Status Bar": "Statusbalk altijd weergeven",
"Translation Not Available": "Vertaling niet beschikbaar",
"Custom Content CSS": "Aangepaste inhoud-CSS",
"Enter CSS for book content styling...": "Voer CSS in voor de opmaak van de boekinhoud...",
"Custom Reader UI CSS": "Aangepaste interface-CSS",
"Enter CSS for reader interface styling...": "Voer CSS in voor de opmaak van de leesinterface...",
"Crop": "Bijsnijden",
"Book Covers": "Boekomslagen",
"Fit": "Passend maken",
"Reset {{settings}}": "Reset {{settings}}",
"Reset Settings": "Instellingen resetten",
"{{count}} pages left in chapter_one": "Nog {{count}} pagina in hoofdstuk",
"{{count}} pages left in chapter_other": "Nog {{count}} paginas in hoofdstuk",
"Show Remaining Pages": "Toon resterende paginas"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Potwierdź usunięcie",
"Copied to notebook": "Skopiowano do notatnika",
"Copy": "Kopiuj",
"Custom CSS": "Własny CSS",
"Dark Mode": "Tryb ciemny",
"Default": "Domyślne",
"Default Font": "Domyślna czcionka",
@@ -28,7 +27,6 @@
"Dictionary": "Słownik",
"Download Readest": "Pobierz Readest",
"Edit": "Edytuj",
"Enter your custom CSS here...": "Wprowadź własny CSS tutaj...",
"Excerpts": "Fragmenty",
"Failed to import book(s): {{filenames}}": "Nie udało się zaimportować książek: {{filenames}}",
"Fast": "Szybko",
@@ -114,8 +112,6 @@
"Your Library": "Twoja biblioteka",
"TTS not supported for PDF": "TTS nie jest obsługiwane dla plików PDF",
"Override Book Font": "Nadpisz czcionkę książki",
"Vertical Margins (px)": "Marginesy pionowe (px)",
"Horizontal Margins (%)": "Marginesy poziome (%)",
"Apply to All Books": "Zastosuj do wszystkich książek",
"Apply to This Book": "Zastosuj do tej książki",
"Unable to fetch the translation. Try again later.": "Nie udało się pobrać tłumaczenia. Spróbuj ponownie później.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Zaloguj się za pomocą Apple",
"Sign in with GitHub": "Zaloguj się za pomocą GitHub",
"Account": "Konto",
"Cloud Storage": "Przestrzeń w chmurze",
"Failed to delete user. Please try again later.": "Nie udało się usunąć użytkownika. Spróbuj ponownie później.",
"Unlimited Offline/Online Reading": "Nieograniczone czytanie offline/online",
"Unlimited Cloud Sync Devices": "Nieograniczona liczba urządzeń synchronizowanych w chmurze",
@@ -267,7 +262,7 @@
"Clear Search": "Wyczyść wyszukiwanie",
"Header & Footer": "Nagłówek i stopka",
"Apply also in Scrolled Mode": "Zastosuj również w trybie przewijania",
"A new version of Readest is Available!": "Nowa wersja Readest jest dostępna!",
"A new version of Readest is available!": "Nowa wersja Readest jest dostępna!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} jest dostępny (zainstalowana wersja {{currentVersion}}).",
"Download and install now?": "Pobierz i zainstaluj teraz?",
"Downloading {{downloaded}} of {{contentLength}}": "Pobieranie {{downloaded}} z {{contentLength}}",
@@ -332,5 +327,60 @@
"Scroll": "Przewijaj",
"Overlap Pixels": "Przesunięcie pikseli",
"Click": "Kliknij",
"System Language": "Język systemu"
"System Language": "Język systemu",
"Security": "Bezpieczeństwo",
"Allow JavaScript": "Zezwól na JavaScript",
"Enable only if you trust the file.": "Zezwól tylko wtedy, gdy ufasz plikowi.",
"Sort TOC by Page": "Sortuj spis treści według strony",
"Search in {{count}} Book(s)..._one": "Szukaj w {{count}} książce...",
"Search in {{count}} Book(s)..._few": "Szukaj w {{count}} książkach...",
"Search in {{count}} Book(s)..._many": "Szukaj w {{count}} książkach...",
"Search in {{count}} Book(s)..._other": "Szukaj w {{count}} książkach...",
"No notes match your search": "Nie znaleziono notatek",
"Search notes and excerpts...": "Szukaj notatek...",
"Sign in to Sync": "Zaloguj się",
"Synced at {{time}}": "Sync: {{time}}",
"Never synced": "Brak synchronizacji",
"Show Remaining Time": "Pokazuj pozostały czas",
"Show Page Number": "Pokaż numer strony",
"{{time}} min left in chapter": "{{time}} min pozostało do końca rozdziału",
"Override Book Color": "Nadpisz kolor książki",
"Login Required": "Wymagane logowanie",
"Quota Exceeded": "Limit przekroczony",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% użytych znaków tłumaczenia dziennego.",
"Translation Characters": "Znaki tłumaczenia",
"Daily translation quota reached. Select another translate service to proceed.": "Przekroczono dzienny limit znaków tłumaczenia. Wybierz inną usługę tłumaczenia, aby kontynuować.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} głos",
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} głosy",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} głosów",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} głosu",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Coś poszło nie tak. Nie martw się, nasz zespół został powiadomiony i pracujemy nad naprawą.",
"Error Details:": "Informacje o błędzie:",
"Try Again": "Spróbuj ponownie",
"Need help?": "Potrzebujesz pomocy?",
"Contact Support": "Skontaktuj się z pomocą techniczną",
"Code Highlighting": "Podświetlanie kodu",
"Enable Highlighting": "Zezwól na podświetlanie",
"Code Language": "Język kodu",
"Top Margin (px)": "Górny margines (px)",
"Bottom Margin (px)": "Dolny margines (px)",
"Right Margin (px)": "Prawy margines (px)",
"Left Margin (px)": "Lewy margines (px)",
"Column Gap (%)": "Odstęp między kolumnami (%)",
"Always Show Status Bar": "Zawsze pokazuj pasek stanu",
"Translation Not Available": "Brak dostępnego tłumaczenia",
"Custom Content CSS": "CSS treści",
"Enter CSS for book content styling...": "Wprowadź CSS dla stylu treści książki...",
"Custom Reader UI CSS": "CSS interfejsu",
"Enter CSS for reader interface styling...": "Wprowadź CSS dla stylu interfejsu czytnika...",
"Crop": "Przytnij",
"Book Covers": "Okładki książek",
"Fit": "Pasuje",
"Reset {{settings}}": "Zresetuj {{settings}}",
"Reset Settings": "Zresetuj ustawienia",
"{{count}} pages left in chapter_one": "Pozostała {{count}} strona w rozdziale",
"{{count}} pages left in chapter_few": "Pozostały {{count}} strony w rozdziale",
"{{count}} pages left in chapter_many": "Pozostało {{count}} stron w rozdziale",
"{{count}} pages left in chapter_other": "Pozostało {{count}} stron w rozdziale",
"Show Remaining Pages": "Pokaż pozostałe strony"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Confirmar Exclusão",
"Copied to notebook": "Copiado para o caderno",
"Copy": "Copiar",
"Custom CSS": "CSS Personalizado",
"Dark Mode": "Modo Escuro",
"Default": "Padrão",
"Default Font": "Fonte Padrão",
@@ -28,7 +27,6 @@
"Dictionary": "Dicionário",
"Download Readest": "Baixar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Insira seu CSS personalizado aqui...",
"Excerpts": "Trechos",
"Failed to import book(s): {{filenames}}": "Falha ao importar livro(s): {{filenames}}",
"Fast": "Rápido",
@@ -114,8 +112,6 @@
"Your Library": "Sua Biblioteca",
"TTS not supported for PDF": "TTS não suportado para PDF",
"Override Book Font": "Sobrescrever Fonte do Livro",
"Vertical Margins (px)": "Margens Verticais (px)",
"Horizontal Margins (%)": "Margens Horizontais (%)",
"Apply to All Books": "Aplicar a todos os livros",
"Apply to This Book": "Aplicar a este livro",
"Unable to fetch the translation. Try again later.": "Não foi possível buscar a tradução. Tente novamente mais tarde.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Entrar com o Apple",
"Sign in with GitHub": "Entrar com o GitHub",
"Account": "Conta",
"Cloud Storage": "Armazenamento na nuvem",
"Failed to delete user. Please try again later.": "Falha ao excluir usuário. Por favor, tente novamente mais tarde.",
"Unlimited Offline/Online Reading": "Leitura offline/online ilimitada",
"Unlimited Cloud Sync Devices": "Sincronização ilimitada de dispositivos na nuvem",
@@ -267,7 +262,7 @@
"Clear Search": "Limpar Pesquisa",
"Header & Footer": "Cabeçalho e Rodapé",
"Apply also in Scrolled Mode": "Aplicar também no Modo Rolado",
"A new version of Readest is Available!": "Uma nova versão do Readest está disponível!",
"A new version of Readest is available!": "Uma nova versão do Readest está disponível!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} está disponível (versão instalada {{currentVersion}}).",
"Download and install now?": "Baixar e instalar agora?",
"Downloading {{downloaded}} of {{contentLength}}": "Baixando {{downloaded}} de {{contentLength}}",
@@ -331,5 +326,57 @@
"Scroll": "Rolar",
"Overlap Pixels": "Sobreposição de Pixels",
"Click": "Clique",
"System Language": "Idioma do Sistema"
"System Language": "Idioma do Sistema",
"Security": "Segurança",
"Allow JavaScript": "Permitir JavaScript",
"Enable only if you trust the file.": "Ativar apenas se você confiar no arquivo.",
"Sort TOC by Page": "Ordenar TOC por Página",
"Search in {{count}} Book(s)..._one": "Pesquisar em {{count}} Livro...",
"Search in {{count}} Book(s)..._many": "Pesquisar em {{count}} Livros...",
"Search in {{count}} Book(s)..._other": "Pesquisar em {{count}} Livros...",
"No notes match your search": "Nenhuma nota encontrada",
"Search notes and excerpts...": "Buscar notas...",
"Sign in to Sync": "Entrar",
"Synced at {{time}}": "Sincronizado às {{time}}",
"Never synced": "Nunca sincronizado",
"Show Remaining Time": "Mostrar Tempo Restante",
"Show Page Number": "Mostrar Número da Página",
"{{time}} min left in chapter": "{{time}} min restantes no capítulo",
"Override Book Color": "Substituir Cor do Livro",
"Login Required": "Login necessário",
"Quota Exceeded": "Limite excedido",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dos caracteres de tradução diários usados.",
"Translation Characters": "Caracteres de Tradução",
"Daily translation quota reached. Select another translate service to proceed.": "Quota diária de tradução atingida. Selecione outro serviço de tradução para continuar.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} voz",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} vozes",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} vozes",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Algo deu errado. Não se preocupe, nossa equipe foi notificada e estamos trabalhando em uma correção.",
"Error Details:": "Detalhes do Erro:",
"Try Again": "Tentar Novamente",
"Need help?": "Precisa de ajuda?",
"Contact Support": "Contato com Suporte",
"Code Highlighting": "Realce de Código",
"Enable Highlighting": "Ativar Realce",
"Code Language": "Idioma do Código",
"Top Margin (px)": "Margem superior (px)",
"Bottom Margin (px)": "Margem inferior (px)",
"Right Margin (px)": "Margem direita (px)",
"Left Margin (px)": "Margem esquerda (px)",
"Column Gap (%)": "Espaço entre colunas (%)",
"Always Show Status Bar": "Exibir sempre a barra de status",
"Translation Not Available": "Tradução não disponível",
"Custom Content CSS": "CSS do conteúdo",
"Enter CSS for book content styling...": "Insira o CSS para o estilo do conteúdo do livro...",
"Custom Reader UI CSS": "CSS da interface",
"Enter CSS for reader interface styling...": "Insira o CSS para o estilo da interface de leitura...",
"Crop": "Cortar",
"Book Covers": "Capas de Livros",
"Fit": "Encaixar",
"Reset {{settings}}": "Redefinir {{settings}}",
"Reset Settings": "Redefinir Configurações",
"{{count}} pages left in chapter_one": "Falta {{count}} página neste capítulo",
"{{count}} pages left in chapter_many": "Faltam {{count}} páginas neste capítulo",
"{{count}} pages left in chapter_other": "Faltam {{count}} páginas neste capítulo",
"Show Remaining Pages": "Mostrar páginas restantes"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Подтвердить удаление",
"Copied to notebook": "Скопировано в блокнот",
"Copy": "Копировать",
"Custom CSS": "Пользовательский CSS",
"Dark Mode": "Тёмная тема",
"Default": "По умолчанию",
"Default Font": "Шрифт по умолчанию",
@@ -28,7 +27,6 @@
"Dictionary": "Словарь",
"Download Readest": "Скачать Readest",
"Edit": "Редактировать",
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
"Excerpts": "Отрывки",
"Failed to import book(s): {{filenames}}": "Не удалось импортировать книгу(и): {{filenames}}",
"Fast": "Быстро",
@@ -114,8 +112,6 @@
"Your Library": "Ваша библиотека",
"TTS not supported for PDF": "TTS не поддерживается для PDF",
"Override Book Font": "Переопределить шрифт книги",
"Vertical Margins (px)": "Вертикальные поля (px)",
"Horizontal Margins (%)": "Горизонтальные поля (%)",
"Apply to All Books": "Применить ко всем книгам",
"Apply to This Book": "Применить к этой книге",
"Unable to fetch the translation. Try again later.": "Не удалось получить перевод. Попробуйте позже.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Войти через Apple",
"Sign in with GitHub": "Войти через GitHub",
"Account": "Аккаунт",
"Cloud Storage": "Облачное хранилище",
"Failed to delete user. Please try again later.": "Не удалось удалить пользователя. Пожалуйста, повторите попытку позже.",
"Unlimited Offline/Online Reading": "Безлимитное чтение офлайн/онлайн",
"Unlimited Cloud Sync Devices": "Неограниченное количество устройств для облачной синхронизации",
@@ -267,7 +262,7 @@
"Clear Search": "Очистить поиск",
"Header & Footer": "Заголовок и нижний колонтитул",
"Apply also in Scrolled Mode": "Применить также в прокручиваемом режиме",
"A new version of Readest is Available!": "Доступна новая версия Readest!",
"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}}": "Скачано {{downloaded}} из {{contentLength}}",
@@ -332,5 +327,60 @@
"Scroll": "Прокрутка",
"Overlap Pixels": "Перекрывающиеся пиксели",
"Click": "Клик",
"System Language": "Системный язык"
"System Language": "Системный язык",
"Security": "Безопасность",
"Allow JavaScript": "Разрешить JavaScript",
"Enable only if you trust the file.": "Включите только если доверяете файлу.",
"Sort TOC by Page": "Сортировать содержание по странице",
"Search in {{count}} Book(s)..._one": "Поиск в {{count}} книге...",
"Search in {{count}} Book(s)..._few": "Поиск в {{count}} книгах...",
"Search in {{count}} Book(s)..._many": "Поиск в {{count}} книгах...",
"Search in {{count}} Book(s)..._other": "Поиск в {{count}} книгах...",
"No notes match your search": "Записи не найдены",
"Search notes and excerpts...": "Искать записи...",
"Sign in to Sync": "Войти",
"Synced at {{time}}": "Синхр. в {{time}}",
"Never synced": "Не синхр.",
"Show Remaining Time": "Показать оставшееся время",
"Show Page Number": "Показать номер страницы",
"{{time}} min left in chapter": "{{time}} мин осталось в главе",
"Override Book Color": "Переопределить цвет книги",
"Login Required": "Требуется вход",
"Quota Exceeded": "Лимит превышен",
"{{percentage}}% of Daily Translation Characters Used.": "Использовано {{percentage}}% от суточного лимита символов перевода.",
"Translation Characters": "Символы перевода",
"Daily translation quota reached. Select another translate service to proceed.": "Достигнут суточный лимит символов перевода. Выберите другой сервис перевода для продолжения.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} голос",
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} голоса",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} голосов",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} голоса",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Что-то пошло не так. Не волнуйтесь, наша команда уведомлена и работает над исправлением.",
"Error Details:": "Подробности ошибки:",
"Try Again": "Попробовать снова",
"Need help?": "Нужна помощь?",
"Contact Support": "Связаться с поддержкой",
"Code Highlighting": "Подсветка кода",
"Enable Highlighting": "Включить подсветку",
"Code Language": "Язык кода",
"Top Margin (px)": "Верхний отступ (пикс)",
"Bottom Margin (px)": "Нижний отступ (пикс)",
"Right Margin (px)": "Правый отступ (пикс)",
"Left Margin (px)": "Левый отступ (пикс)",
"Column Gap (%)": "Промежуток между колонками (%)",
"Always Show Status Bar": "Всегда показывать панель состояния",
"Translation Not Available": "Перевод недоступен",
"Custom Content CSS": "CSS содержимого",
"Enter CSS for book content styling...": "Введите CSS для оформления содержимого книги...",
"Custom Reader UI CSS": "CSS интерфейса",
"Enter CSS for reader interface styling...": "Введите CSS для оформления интерфейса ридера...",
"Crop": "Обрезать",
"Book Covers": "Обложки книг",
"Fit": "Подогнать",
"Reset {{settings}}": "Сбросить {{settings}}",
"Reset Settings": "Сбросить настройки",
"{{count}} pages left in chapter_one": "Осталась {{count}} страница в главе",
"{{count}} pages left in chapter_few": "Осталось {{count}} страницы в главе",
"{{count}} pages left in chapter_many": "Осталось {{count}} страниц в главе",
"{{count}} pages left in chapter_other": "Осталось {{count}} страниц в главе",
"Show Remaining Pages": "Показать оставшиеся страницы"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Silmeyi Onayla",
"Copied to notebook": "Not defterine kopyalandı",
"Copy": "Kopyala",
"Custom CSS": "Özel CSS",
"Dark Mode": "Karanlık Mod",
"Default": "Varsayılan",
"Default Font": "Varsayılan Yazı Tipi",
@@ -28,7 +27,6 @@
"Dictionary": "Sözlük",
"Download Readest": "Readest'i İndir",
"Edit": "Düzenle",
"Enter your custom CSS here...": "Özel CSS'nizi buraya girin...",
"Excerpts": "Alıntılar",
"Failed to import book(s): {{filenames}}": "Kitap(lar) içe aktarılamadı: {{filenames}}",
"Fast": "Hızlı",
@@ -114,8 +112,6 @@
"Your Library": "Kütüphaneniz",
"TTS not supported for PDF": "PDF için TTS desteklenmiyor",
"Override Book Font": "Kitap Yazı Tipini Geçersiz Kıl",
"Vertical Margins (px)": "Dikey Kenar Boşlukları (px)",
"Horizontal Margins (%)": "Yatay Kenar Boşlukları (%)",
"Apply to All Books": "Tüm kitaplara uygula",
"Apply to This Book": "Bu kitaba uygula",
"Unable to fetch the translation. Try again later.": "Çeviri alınamıyor. Daha sonra tekrar deneyin.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Apple ile giriş yap",
"Sign in with GitHub": "GitHub ile giriş yap",
"Account": "Hesap",
"Cloud Storage": "Bulut Depolama",
"Failed to delete user. Please try again later.": "Kullanıcı silinemedi. Lütfen daha sonra tekrar deneyin.",
"Unlimited Offline/Online Reading": "Sınırsız Çevrimdışı/Çevrimiçi Okuma",
"Unlimited Cloud Sync Devices": "Sınırsız Bulut Senkronizasyon Cihazları",
@@ -267,7 +262,7 @@
"Clear Search": "Aramayı Temizle",
"Header & Footer": "Başlık ve Altbilgi",
"Apply also in Scrolled Mode": "Kaydırılmış Modda da Uygula",
"A new version of Readest is Available!": "Yeni bir Readest sürümü mevcut!",
"A new version of Readest is available!": "Yeni bir Readest sürümü mevcut!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} mevcut (yüklü sürüm {{currentVersion}}).",
"Download and install now?": "Şimdi indirip yükleyelim mi?",
"Downloading {{downloaded}} of {{contentLength}}": "İndiriliyor {{downloaded}}/{{contentLength}}",
@@ -330,5 +325,54 @@
"Scroll": "Kaydır",
"Overlap Pixels": "Örtüşen Pikseller",
"Click": "Klik",
"System Language": "System Dili"
"System Language": "System Dili",
"Security": "Security",
"Allow JavaScript": "JavaScript'e İzin Ver",
"Enable only if you trust the file.": "Yalnızca dosyaya güveniyorsanız etkinleştirin.",
"Sort TOC by Page": "İçindekileri Sayfaya Göre Sırala",
"Search in {{count}} Book(s)..._one": "{{count}} kitapta ara...",
"Search in {{count}} Book(s)..._other": "{{count}} kitapta ara...",
"No notes match your search": "Eşleşen not bulunamadı",
"Search notes and excerpts...": "Notlarda ara...",
"Sign in to Sync": "Giriş yap",
"Synced at {{time}}": "{{time}}'de eşzlendi",
"Never synced": "Hiç eşzlenmedi",
"Show Remaining Time": "Kalan Süreyi Göster",
"Show Page Number": "Sayfa Numarasını Göster",
"{{time}} min left in chapter": "{{time}} dakika bölümde kaldı",
"Override Book Color": "Kitap Rengini Geçersiz Kıl",
"Login Required": "Giriş gerekli",
"Quota Exceeded": "Kota aşıldı",
"{{percentage}}% of Daily Translation Characters Used.": "Çeviri için günlük karakterlerin %{{percentage}}'si kullanıldı.",
"Translation Characters": "Çeviri Karakterleri",
"Daily translation quota reached. Select another translate service to proceed.": "Günlük çeviri kotası aşıldı. Devam etmek için başka bir çeviri hizmeti seçin.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} ses",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} sesler",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Bir şeyler yanlış gitti. Endişelenmeyin, ekibimiz bilgilendirildi ve bir çözüm üzerinde çalışıyoruz.",
"Error Details:": "Error Detayları:",
"Try Again": "Tekrar Dene",
"Need help?": "Yardıma mı ihtiyacınız var?",
"Contact Support": "Destekle İletişime Geç",
"Code Highlighting": "Code Vurgulama",
"Enable Highlighting": "Vurgulamayı Etkinleştir",
"Code Language": "Code Dili",
"Top Margin (px)": "Üst Kenar Boşluğu (px)",
"Bottom Margin (px)": "Alt Kenar Boşluğu (px)",
"Right Margin (px)": "Sağ Kenar Boşluğu (px)",
"Left Margin (px)": "Sol Kenar Boşluğu (px)",
"Column Gap (%)": "Sütun Aralığı (%)",
"Always Show Status Bar": "Durum Çubuğunu Her Zaman Göster",
"Translation Not Available": "Çeviri Mevcut Değil",
"Custom Content CSS": "Özel İçerik CSS'si",
"Enter CSS for book content styling...": "Kitap içeriği stili için CSS girin...",
"Custom Reader UI CSS": "Özel Okuyucu Arayüzü CSS'si",
"Enter CSS for reader interface styling...": "Okuyucu arayüzü stili için CSS girin...",
"Crop": "Kes",
"Book Covers": "Kitap Kapağı",
"Fit": "Uygun",
"Reset {{settings}}": "{{settings}}'i Sıfırla",
"Reset Settings": "Ayarları Sıfırla",
"{{count}} pages left in chapter_one": "Bu bölümde {{count}} sayfa kaldı",
"{{count}} pages left in chapter_other": "Bu bölümde {{count}} sayfa kaldı",
"Show Remaining Pages": "Kalan sayfaları göster"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Підтвердити видалення",
"Copied to notebook": "Скопійовано до нотатника",
"Copy": "Копіювати",
"Custom CSS": "Користувацький CSS",
"Dark Mode": "Темний режим",
"Default": "За замовчуванням",
"Default Font": "Шрифт за замовчуванням",
@@ -28,7 +27,6 @@
"Dictionary": "Словник",
"Download Readest": "Завантажити Readest",
"Edit": "Редагувати",
"Enter your custom CSS here...": "Введіть ваш користувацький CSS тут...",
"Excerpts": "Уривки",
"Failed to import book(s): {{filenames}}": "Не вдалося імпортувати книгу(и): {{filenames}}",
"Fast": "Швидко",
@@ -114,8 +112,6 @@
"Your Library": "Ваша бібліотека",
"TTS not supported for PDF": "TTS не підтримується для PDF",
"Override Book Font": "Перевизначити шрифт книги",
"Vertical Margins (px)": "Вертикальні відступи (px)",
"Horizontal Margins (%)": "Горизонтальні відступи (%)",
"Apply to All Books": "Застосувати до всіх книг",
"Apply to This Book": "Застосувати до цієї книги",
"Unable to fetch the translation. Try again later.": "Не вдалося отримати переклад. Спробуйте пізніше.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Увійти через Apple",
"Sign in with GitHub": "Увійти через GitHub",
"Account": "Обліковий запис",
"Cloud Storage": "Хмарне сховище",
"Failed to delete user. Please try again later.": "Не вдалося видалити користувача. Будь ласка, спробуйте пізніше.",
"Unlimited Offline/Online Reading": "Необмежене читання офлайн/онлайн",
"Unlimited Cloud Sync Devices": "Необмежена кількість пристроїв для хмарної синхронізації",
@@ -267,7 +262,7 @@
"Clear Search": "Очистити пошук",
"Header & Footer": "Заголовок і нижній колонтитул",
"Apply also in Scrolled Mode": "Застосувати також у прокрученому режимі",
"A new version of Readest is Available!": "Доступна нова версія Readest!",
"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}}": "Завантаження {{downloaded}} з {{contentLength}}",
@@ -332,5 +327,60 @@
"Scroll": "Прокрутка",
"Overlap Pixels": "Пікселі перекриття",
"Click": "Клацнути",
"System Language": "Системна мова"
"System Language": "Системна мова",
"Security": "Безпека",
"Allow JavaScript": "Дозволити JavaScript",
"Enable only if you trust the file.": "Увімкніть лише якщо довіряєте файлу.",
"Sort TOC by Page": "Сортувати зміст за сторінкою",
"Search in {{count}} Book(s)..._one": "Шукати в {{count}} книзі...",
"Search in {{count}} Book(s)..._few": "Шукати в {{count}} книгах...",
"Search in {{count}} Book(s)..._many": "Шукати в {{count}} книгах...",
"Search in {{count}} Book(s)..._other": "Шукати в {{count}} книгах...",
"No notes match your search": "Нотаток не знайдено",
"Search notes and excerpts...": "Шукати нотатки...",
"Sign in to Sync": "Увійти",
"Synced at {{time}}": "Синхр. о {{time}}",
"Never synced": "Ніколи не синхр.",
"Show Remaining Time": "Показати залишок часу",
"Show Page Number": "Показати номер сторінки",
"{{time}} min left in chapter": "{{time}} хв до кінця розділу",
"Override Book Color": "Перевизначити колір книги",
"Login Required": "Потрібен вхід",
"Quota Exceeded": "Перевищено ліміт",
"{{percentage}}% of Daily Translation Characters Used.": "Використано {{percentage}}% символів перекладу за день.",
"Translation Characters": "Символи перекладу",
"Daily translation quota reached. Select another translate service to proceed.": "Досягнуто добового ліміту перекладу. Виберіть іншу службу перекладу для продовження.",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} голос",
"{{engine}}: {{count}} voices_few": "{{engine}}: {{count}} голоси",
"{{engine}}: {{count}} voices_many": "{{engine}}: {{count}} голосів",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} голоси",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Щось пішло не так. Не хвилюйтеся, наша команда була сповіщена, і ми працюємо над виправленням.",
"Error Details:": "Деталі помилки:",
"Try Again": "Спробувати ще раз",
"Need help?": "Потрібна допомога?",
"Contact Support": "Зв'язатися з підтримкою",
"Code Highlighting": "Підсвічування коду",
"Enable Highlighting": "Увімкнути підсвічування",
"Code Language": "Мова коду",
"Top Margin (px)": "Верхній відступ (px)",
"Bottom Margin (px)": "Нижній відступ (px)",
"Right Margin (px)": "Правий відступ (px)",
"Left Margin (px)": "Лівий відступ (px)",
"Column Gap (%)": "Проміжок між стовпцями (%)",
"Always Show Status Bar": "Завжди показувати панель стану",
"Translation Not Available": "Переклад недоступний",
"Custom Content CSS": "CSS вмісту",
"Enter CSS for book content styling...": "Введіть CSS для стилізації вмісту книги...",
"Custom Reader UI CSS": "CSS інтерфейсу",
"Enter CSS for reader interface styling...": "Введіть CSS для стилізації інтерфейсу рідера...",
"Crop": "Обрізати",
"Book Covers": "Обкладинки книг",
"Fit": "Вписати",
"Reset {{settings}}": "Скинути {{settings}}",
"Reset Settings": "Скинути налаштування",
"{{count}} pages left in chapter_one": "Залишилася {{count}} сторінка у розділі",
"{{count}} pages left in chapter_few": "Залишилось {{count}} сторінки у розділі",
"{{count}} pages left in chapter_many": "Залишилось {{count}} сторінок у розділі",
"{{count}} pages left in chapter_other": "Залишилось {{count}} сторінок у розділі",
"Show Remaining Pages": "Показати залишок сторінок"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "Xác nhận xóa",
"Copied to notebook": "Đã sao chép vào sổ ghi chú",
"Copy": "Sao chép",
"Custom CSS": "CSS tùy chỉnh",
"Dark Mode": "Chế độ tối",
"Default": "Mặc định",
"Default Font": "Phông chữ mặc định",
@@ -28,7 +27,6 @@
"Dictionary": "Từ điển",
"Download Readest": "Tải Readest",
"Edit": "Chỉnh sửa",
"Enter your custom CSS here...": "Nhập CSS tùy chỉnh của bạn vào đây...",
"Excerpts": "Trích dẫn",
"Failed to import book(s): {{filenames}}": "Không thể nhập sách: {{filenames}}",
"Fast": "Nhanh",
@@ -114,8 +112,6 @@
"Your Library": "Thư viện của bạn",
"TTS not supported for PDF": "TTS không được hỗ trợ cho PDF",
"Override Book Font": "Ghi đè phông chữ sách",
"Vertical Margins (px)": "Lề dọc (px)",
"Horizontal Margins (%)": "Lề ngang (%)",
"Apply to All Books": "Áp dụng cho tất cả sách",
"Apply to This Book": "Áp dụng cho sách này",
"Unable to fetch the translation. Try again later.": "Không thể tải dịch vụ. Vui lòng thử lại sau.",
@@ -186,7 +182,6 @@
"Sign in with Apple": "Đăng nhập với Apple",
"Sign in with GitHub": "Đăng nhập với GitHub",
"Account": "Tài khoản",
"Cloud Storage": "Lưu trữ đám mây",
"Failed to delete user. Please try again later.": "Không thể xóa người dùng. Vui lòng thử lại sau.",
"Unlimited Offline/Online Reading": "Đọc ngoại tuyến/trực tuyến không giới hạn",
"Unlimited Cloud Sync Devices": "Đồng bộ hóa thiết bị đám mây không giới hạn",
@@ -267,7 +262,7 @@
"Clear Search": "Xóa tìm kiếm",
"Header & Footer": "Đầu trang & Chân trang",
"Apply also in Scrolled Mode": "Áp dụng cũng trong chế độ cuộn",
"A new version of Readest is Available!": "Đã có phiên bản mới của Readest!",
"A new version of Readest is available!": "Đã có phiên bản mới của Readest!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} đã có sẵn (phiên bản đã cài đặt {{currentVersion}}).",
"Download and install now?": "Tải xuống và cài đặt ngay bây giờ?",
"Downloading {{downloaded}} of {{contentLength}}": "Đang tải {{downloaded}} trong {{contentLength}}",
@@ -329,5 +324,51 @@
"Scroll": "Cuộn",
"Overlap Pixels": "Chồng lấn pixel",
"Click": "Nhấp",
"System Language": "Ngôn ngữ hệ thống"
"System Language": "Ngôn ngữ hệ thống",
"Security": "An ninh",
"Allow JavaScript": "Cho phép JavaScript",
"Enable only if you trust the file.": "Chỉ bật nếu bạn tin tưởng tệp.",
"Sort TOC by Page": "Sort Mục lục theo Trang",
"Search in {{count}} Book(s)..._other": "Tìm kiếm trong {{count}} cuốn sách...",
"No notes match your search": "Không tìm thấy ghi chú nào",
"Search notes and excerpts...": "Tìm ghi chú...",
"Sign in to Sync": "Đăng nhập",
"Synced at {{time}}": "Đã đồng bộ lúc {{time}}",
"Never synced": "Chưa từng đồng bộ",
"Show Remaining Time": "Hiển thị thời gian còn lại",
"Show Page Number": "Hiển thị số trang",
"{{time}} min left in chapter": "{{time}} phút còn lại trong chương",
"Override Book Color": "Đổi màu sách",
"Login Required": "Cần đăng nhập",
"Quota Exceeded": "Vượt hạn mức",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% số ký tự dịch hàng ngày đã sử dụng.",
"Translation Characters": "Những ký tự dịch",
"Daily translation quota reached. Select another translate service to proceed.": "Đã đạt hạn mức dịch hàng ngày. Chọn dịch vụ dịch khác để tiếp tục.",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} giọng",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Đã xảy ra sự cố. Đừng lo, nhóm của chúng tôi đã được thông báo và chúng tôi đang làm việc để sửa chữa.",
"Error Details:": "Chi tiết lỗi:",
"Try Again": "Thử lại",
"Need help?": "Bạn cần giúp đỡ?",
"Contact Support": "Liên hệ hỗ trợ",
"Code Highlighting": "Đánh dấu mã",
"Enable Highlighting": "Cho phép đánh dấu",
"Code Language": "Ngôn ngữ mã",
"Top Margin (px)": "Lề trên (px)",
"Bottom Margin (px)": "Lề dưới (px)",
"Right Margin (px)": "Lề phải (px)",
"Left Margin (px)": "Lề trái (px)",
"Column Gap (%)": "Khoảng cách cột (%)",
"Always Show Status Bar": "Luôn hiển thị thanh trạng thái",
"Translation Not Available": "Không có bản dịch",
"Custom Content CSS": "CSS nội dung",
"Enter CSS for book content styling...": "Nhập CSS cho phần nội dung sách...",
"Custom Reader UI CSS": "CSS giao diện đọc",
"Enter CSS for reader interface styling...": "Nhập CSS cho giao diện trình đọc...",
"Crop": "Cắt",
"Book Covers": "Bìa sách",
"Fit": "Vừa vặn",
"Reset {{settings}}": "Đặt lại {{settings}}",
"Reset Settings": "Đặt lại cài đặt",
"{{count}} pages left in chapter_other": "Còn {{count}} trang trong chương",
"Show Remaining Pages": "Hiển thị trang còn lại"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "确认删除",
"Copied to notebook": "已复制到笔记本",
"Copy": "复制",
"Custom CSS": "自定义 CSS",
"Dark Mode": "深色主题",
"Default": "默认",
"Default Font": "默认字体",
@@ -28,7 +27,6 @@
"Dictionary": "词典",
"Download Readest": "下载 Readest",
"Edit": "编辑",
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
"Excerpts": "摘录",
"Failed to import book(s): {{filenames}}": "导入书籍失败:{{filenames}}",
"Fast": "快",
@@ -114,8 +112,6 @@
"Your Library": "书库",
"TTS not supported for PDF": "PDF 文档暂不支持 TTS",
"Override Book Font": "覆盖书籍字体",
"Vertical Margins (px)": "垂直边距(px",
"Horizontal Margins (%)": "水平边距(%",
"Apply to All Books": "应用于所有书籍",
"Apply to This Book": "应用于此书籍",
"Unable to fetch the translation. Try again later.": "无法获取翻译,请稍后重试。",
@@ -186,7 +182,6 @@
"Sign in with Apple": "使用 Apple 登录",
"Sign in with GitHub": "使用 GitHub 登录",
"Account": "账户",
"Cloud Storage": "云存储",
"Failed to delete user. Please try again later.": "删除用户失败。请稍后重试。",
"Unlimited Offline/Online Reading": "无限离线和在线阅读",
"Unlimited Cloud Sync Devices": "无限云同步设备个数",
@@ -267,7 +262,7 @@
"Clear Search": "清除搜索",
"Header & Footer": "页眉页脚",
"Apply also in Scrolled Mode": "应用到滚动模式",
"A new version of Readest is Available!": "有新版本的 Readest 可更新!",
"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}}": "正在下载 {{downloaded}} / {{contentLength}}",
@@ -329,5 +324,51 @@
"Scroll": "滚动",
"Overlap Pixels": "重叠像素",
"Click": "点击",
"System Language": "系统语言"
"System Language": "系统语言",
"Security": "安全",
"Allow JavaScript": "允许 JavaScript",
"Enable only if you trust the file.": "建议仅在您信任该文件时启用",
"Sort TOC by Page": "按页码排序目录",
"Search in {{count}} Book(s)..._other": "在 {{count}} 本书籍中搜索...",
"No notes match your search": "未找到匹配的笔记",
"Search notes and excerpts...": "搜索笔记...",
"Sign in to Sync": "登录后同步",
"Synced at {{time}}": "同步于:{{time}}",
"Never synced": "从未同步",
"Show Remaining Time": "显示剩余时间",
"Show Page Number": "显示页码",
"{{time}} min left in chapter": "本章剩余 {{time}} 分钟",
"Override Book Color": "覆盖书籍颜色",
"Login Required": "需登录",
"Quota Exceeded": "额度不足",
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻译字符数",
"Translation Characters": "翻译字数",
"Daily translation quota reached. Select another translate service to proceed.": "已达到每日翻译配额,请选择其他翻译服务继续。",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种声音",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "出了点问题。请不要担心,我们的团队已经收到通知并正在修复中。",
"Error Details:": "错误详情:",
"Try Again": "重试",
"Need help?": "需要帮助吗?",
"Contact Support": "联系客服",
"Code Highlighting": "代码高亮",
"Enable Highlighting": "启用高亮",
"Code Language": "代码语言",
"Top Margin (px)": "上边距",
"Bottom Margin (px)": "下边距",
"Right Margin (px)": "右边距",
"Left Margin (px)": "左边距",
"Column Gap (%)": "列间距",
"Always Show Status Bar": "始终显示状态栏",
"Translation Not Available": "翻译不可用",
"Custom Content CSS": "自定义内容 CSS",
"Enter CSS for book content styling...": "输入书籍内容样式的CSS…",
"Custom Reader UI CSS": "自定义界面 CSS",
"Enter CSS for reader interface styling...": "输入阅读器界面样式的CSS…",
"Crop": "裁剪",
"Book Covers": "书籍封面",
"Fit": "适应",
"Reset {{settings}}": "重置{{settings}}",
"Reset Settings": "重置设置",
"{{count}} pages left in chapter_other": "本章剩余 {{count}} 页",
"Show Remaining Pages": "显示剩余页数"
}
@@ -18,7 +18,6 @@
"Confirm Deletion": "確認刪除",
"Copied to notebook": "已複製到筆記本",
"Copy": "複製",
"Custom CSS": "自定義 CSS",
"Dark Mode": "深色主題",
"Default": "預設",
"Default Font": "預設字體",
@@ -28,7 +27,6 @@
"Dictionary": "詞典",
"Download Readest": "下載 Readest",
"Edit": "編輯",
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
"Excerpts": "摘錄",
"Failed to import book(s): {{filenames}}": "導入書籍失敗:{{filenames}}",
"Fast": "快",
@@ -114,8 +112,6 @@
"Your Library": "書庫",
"TTS not supported for PDF": "PDF 文檔暫不支持 TTS",
"Override Book Font": "覆蓋書籍字體",
"Vertical Margins (px)": "垂直邊距(px",
"Horizontal Margins (%)": "水平邊距(%",
"Apply to All Books": "應用於所有書籍",
"Apply to This Book": "應用於此書籍",
"Unable to fetch the translation. Try again later.": "無法獲取翻譯,請稍後再試。",
@@ -186,7 +182,6 @@
"Sign in with Apple": "使用 Apple 登入",
"Sign in with GitHub": "使用 GitHub 登入",
"Account": "帳戶",
"Cloud Storage": "雲端儲存空間",
"Failed to delete user. Please try again later.": "刪除使用者失敗。請稍後再試。",
"Unlimited Offline/Online Reading": "無限離線/線上閱讀",
"Unlimited Cloud Sync Devices": "無限雲端同步裝置",
@@ -267,7 +262,7 @@
"Clear Search": "清除搜索",
"Header & Footer": "頁眉頁腳",
"Apply also in Scrolled Mode": "滾動模式同樣適用",
"A new version of Readest is Available!": "有新版本的 Readest 可更新!",
"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}}": "正在下載 {{downloaded}} / {{contentLength}}",
@@ -329,5 +324,51 @@
"Scroll": "滾動",
"Overlap Pixels": "重疊像素",
"Click": "點擊",
"System Language": "系統語言"
"System Language": "系統語言",
"Security": "安全",
"Allow JavaScript": "允許 JavaScript",
"Enable only if you trust the file.": "建議僅在您信任該檔案時啟用",
"Sort TOC by Page": "依頁碼排序目錄",
"Search in {{count}} Book(s)..._other": "在 {{count}} 本書中搜索...",
"No notes match your search": "找不到符合的筆記",
"Search notes and excerpts...": "搜尋筆記...",
"Sign in to Sync": "登入以同步",
"Synced at {{time}}": "同步於:{{time}}",
"Never synced": "從未同步",
"Show Remaining Time": "顯示剩餘時間",
"Show Page Number": "顯示頁碼",
"{{time}} min left in chapter": "本章剩餘 {{time}} 分鐘",
"Override Book Color": "覆蓋書籍顏色",
"Login Required": "需登入",
"Quota Exceeded": "額度不足",
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻譯字數",
"Translation Characters": "翻譯字數",
"Daily translation quota reached. Select another translate service to proceed.": "已達到每日翻譯字數限制,請選擇其他翻譯服務繼續。",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 種語音",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "出了點問題。別擔心,我們的團隊已經收到通知,正在努力修復。",
"Error Details:": "錯誤詳情:",
"Try Again": "再試一次",
"Need help?": "需要幫助嗎?",
"Contact Support": "聯繫支援",
"Code Highlighting": "程式碼高亮",
"Enable Highlighting": "啟用高亮",
"Code Language": "程式語言",
"Top Margin (px)": "上邊距",
"Bottom Margin (px)": "下邊距",
"Right Margin (px)": "右邊距",
"Left Margin (px)": "左邊距",
"Column Gap (%)": "欄間距",
"Always Show Status Bar": "始終顯示狀態欄",
"Translation Not Available": "翻譯不可用",
"Custom Content CSS": "自訂內容 CSS",
"Enter CSS for book content styling...": "輸入書籍內容樣式的CSS…",
"Custom Reader UI CSS": "自訂介面 CSS",
"Enter CSS for reader interface styling...": "輸入閱讀器介面樣式的CSS…",
"Crop": "裁剪",
"Book Covers": "書籍封面",
"Fit": "適應",
"Reset {{settings}}": "重置{{settings}}",
"Reset Settings": "重置設置",
"{{count}} pages left in chapter_other": "本章剩餘 {{count}} 頁",
"Show Remaining Pages": "顯示剩餘頁數"
}
+106
View File
@@ -1,5 +1,111 @@
{
"releases": {
"0.9.61": {
"date": "2025-06-26",
"notes": [
"Layout: Fixed view menu width on smaller screens",
"Layout: Added option to crop or fit book covers in library view",
"Reader: You can now apply custom CSS to the reading interface (UI)",
"Reader: Added an option to show remaining pages in the current chapter",
"Settings: Added options to reset settings from the config dialog",
"Import: Fixed encoded filenames when importing TXT files on iOS",
"Linux: Fixed missing app icon on some Linux distributions",
"GitHub: Added donation link to support Readest development"
]
},
"0.9.60": {
"date": "2025-06-22",
"notes": [
"Layout has been improved for iPad devices",
"Screen orientation changes are now smoother on iOS",
"Importing books on iOS during the first launch is now more reliable",
"Font size no longer changes unexpectedly in scrolled mode on iOS",
"Top and bottom safe insets are now ignored when no header or footer is present",
"Interactive books now load correctly on the Desktop platform",
"Translation is now disabled when the primary language is undefined",
"Android: the task list background color has been updated with current theme color"
]
},
"0.9.59": {
"date": "2025-06-19",
"notes": [
"Layout: Image size now adjusts more accurately by converting screen units to pixels properly",
"Layout: Improved header layout on iOS landscape mode — extra spacing has been removed",
"Settings: Top and bottom margin settings are now more intuitive and better constrained"
]
},
"0.9.58": {
"date": "2025-06-18",
"notes": [
"TTS: Translation now works even when TTS is playing in the background",
"TTS: Press T on your keyboard to quickly toggle TTS on or off",
"TTS: Each open book now has its own independent TTS controller",
"Annotations: Annotation tools now function properly while TTS is active",
"Settings: Margins for top, bottom, left, and right can now be adjusted individually",
"Settings: New option to always show the status bar while reading",
"Layout: Reader page now adapts better to device safe areas for a more comfortable layout",
"UI: Added creation timestamps to annotations and made various interface improvements",
"iPad: Multiple columns are now supported in portrait mode",
"iPad: Fixed issue where sidebars couldn't be resized by dragging",
"iPad: Added support for split-screen mode"
]
},
"0.9.57": {
"date": "2025-06-14",
"notes": ["TTS: Fixed an issue to detect custom TTS engines on some Android devices"]
},
"0.9.56": {
"date": "2025-06-13",
"notes": [
"Performance: Improved TOC loading for books with very large chapter lists",
"TTS: Added support for native Android TTS engines (e.g., MultiTTS)",
"TTS: Fixed an issue where the next chapter would not load properly in the background",
"Translation: Improved accuracy and clarity of some Arabic translations",
"Reading: Added support for code syntax highlighting in books with code snippets"
]
},
"0.9.55": {
"date": "2025-06-07",
"notes": [
"Sync: Notes deleted on other devices now correctly sync across all platforms",
"Layout: Now text justification can be unset in the layout settings",
"Translation: Added notification when daily translation quota is exceeded",
"Performance: Reduced unnecessary animations on Android to improve efficiency"
]
},
"0.9.53": {
"date": "2025-06-05",
"notes": [
"Reader: New option to customize book foreground and background colors",
"Reader: Fixed an issue where images appeared too small in some EPUBs",
"EPUB: Improved cover image detection for certain EPUB files",
"TTS: Improved background playback stability on iOS devices",
"Translation: Introduced a daily DeepL translation quota to ensure long-term availability"
]
},
"0.9.52": {
"date": "2025-06-03",
"notes": [
"Library: The search bar now shows the number of books currently displayed",
"Book Details: Book descriptions now support rich HTML formatting",
"Notebook: Added support for writing notes using Markdown",
"Notebook: You can now search within your notes",
"Sync: A sync status item has been added to the View menu",
"TOC: You can now sort the table of contents by page number",
"Reader: Option added to show remaining time in the current chapter"
]
},
"0.9.51": {
"date": "2025-06-01",
"notes": [
"Translator: Table of contents is now included in translations",
"Library Theme: Added light, dark, and system theme options to the library page",
"TTS: Now shows the current sentence and chapter in the media session",
"TTS: Text-to-speech continues from the last read sentence",
"Pagination Fix: Taking notes no longer breaks page turning",
"EPUB Scripts: Added a setting to allow JavaScript in EPUB files"
]
},
"0.9.50": {
"date": "2025-05-28",
"notes": [
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
set -e
VERSION=$(jq -r '.version' package.json)
if [[ -z "$VERSION" ]]; then
echo "❌ Failed to extract version from package.json"
exit 1
fi
echo "📦 Found version: $VERSION"
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
if [[ -z "$MAJOR" || -z "$MINOR" || -z "$PATCH" ]]; then
echo "❌ Invalid version format: $VERSION"
exit 1
fi
# Convert x.y.z => x * 10000 + y * 1000 + z
VERSION_CODE=$((10#$MAJOR * 10000 + 10#$MINOR * 1000 + 10#$PATCH))
echo "🔢 Computed versionCode: $VERSION_CODE"
PROPERTIES_FILE="./src-tauri/gen/android/app/tauri.properties"
if [[ ! -f "$PROPERTIES_FILE" ]]; then
echo "❌ File not found: $PROPERTIES_FILE"
exit 1
fi
tmpfile=$(mktemp)
sed "s/^tauri\.android\.versionName=.*/tauri.android.versionName=$VERSION/" "$PROPERTIES_FILE" | \
sed "s/^tauri\.android\.versionCode=.*/tauri.android.versionCode=$VERSION_CODE/" > "$tmpfile"
mv "$tmpfile" "$PROPERTIES_FILE"
echo "✅ Updated $PROPERTIES_FILE"
echo "🚀 Running: pnpm tauri android build"
pnpm tauri android build
+2 -1
View File
@@ -32,7 +32,7 @@ reqwest = { version = "0.12", default-features = false, features = [
] }
# FIXME: remove the devtools feature in production
tauri = { version = "2.5.1", features = [ "protocol-asset", "devtools"] }
tauri-build = "2.1.1"
tauri-build = "2.3.0"
tauri-plugin-log = "2.4.0"
tauri-plugin-fs = "2.2.1"
tauri-plugin-dialog = "2.2.1"
@@ -46,6 +46,7 @@ tauri-plugin-deep-link = "2.2.1"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.4"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
+2 -2
View File
@@ -11,7 +11,7 @@
<key>UIHomeIndicatorAutoHidden</key>
<true/>
<key>UIRequiresFullScreen</key>
<true/>
<false/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -198,4 +198,4 @@
</dict>
</array>
</dict>
</plist>
</plist>
@@ -54,11 +54,14 @@
"identifier": "http:default",
"allow": [
{
"url": "https://*.deepl.com"
"url": "https://*.readest.com"
},
{
"url": "https://github.com/readest/*"
},
{
"url": "https://*.deepl.com"
},
{
"url": "https://*.cloudflarestorage.com"
},
@@ -104,6 +107,7 @@
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
"native-bridge:default",
"native-tts:default",
"deep-link:default"
]
}
@@ -1,7 +1,6 @@
<?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"/>
@@ -10,14 +9,17 @@
<application
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:largeHeap="true"
android:label="@string/app_name"
android:theme="@style/Theme.readest"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:hardwareAccelerated="true"
android:name=".MainActivity"
android:exported="true">
@@ -40,15 +42,15 @@
<data android:mimeType="application/zip" />
<data android:mimeType="text/plain" />
<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" />
<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>
<intent-filter android:label="oauth">
@@ -68,10 +70,9 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:scheme="http" />
<data android:host="web.readest.com" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
@@ -1,10 +1,14 @@
package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import android.view.KeyEvent
import android.webkit.WebView
import android.util.Log
import android.graphics.Color
import android.app.ActivityManager
import android.content.res.Configuration
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -77,5 +81,15 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
setTaskDescription(
ActivityManager.TaskDescription(
getString(R.string.app_name),
null,
Color.TRANSPARENT
)
)
}
}
}
@@ -2,6 +2,9 @@
<!-- 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:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">false</item>
@@ -154,9 +154,8 @@ class VolumeKeyHandler: NSObject {
class NativeBridgePlugin: Plugin {
private var webView: WKWebView?
private var authSession: ASWebAuthenticationSession?
private var isOrientationLocked = false
private var currentOrientationMask: UIInterfaceOrientationMask = .all
private var orientationObserver: NSObjectProtocol?
private var originalDelegate: UIApplicationDelegate?
@objc public override func load(webview: WKWebView) {
self.webView = webview
@@ -175,16 +174,19 @@ class NativeBridgePlugin: Plugin {
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
if let app = UIApplication.value(forKey: "sharedApplication") as? UIApplication {
self.originalDelegate = app.delegate
app.delegate = self
} else {
Logger.error("NativeBridgePlugin: Failed to get shared application")
}
}
@objc func appDidBecomeActive() {
if volumeKeyHandler != nil {
activateVolumeKeyInterception()
}
if orientationObserver != nil {
self.setupOrientationObserver()
}
}
@objc func appDidEnterBackground() {
@@ -360,18 +362,11 @@ class NativeBridgePlugin: Plugin {
let orientation = args.orientation ?? "auto"
switch orientation.lowercased() {
case "portrait":
self.isOrientationLocked = true
self.currentOrientationMask = .portrait
self.forceOrientation(.portrait)
self.setupOrientationObserver()
self.changeOrientation(.portrait)
case "landscape":
self.isOrientationLocked = true
self.currentOrientationMask = .landscape
self.forceOrientation(.landscapeRight)
self.setupOrientationObserver()
self.changeOrientation(.landscape)
case "auto":
self.isOrientationLocked = false
self.currentOrientationMask = .all
self.changeOrientation(.all)
default:
invoke.reject("Invalid orientation mode")
return
@@ -381,40 +376,49 @@ class NativeBridgePlugin: Plugin {
}
}
private func forceOrientation(_ orientation: UIInterfaceOrientation) {
private func changeOrientation(_ orientationMask: UIInterfaceOrientationMask) {
self.currentOrientationMask = orientationMask
if #available(iOS 16.0, *) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
let orientationMask: UIInterfaceOrientationMask =
orientation.isPortrait ? .portrait : .landscape
for window in windowScene.windows {
if let rootVC = window.rootViewController {
rootVC.setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask)) { error in
print("Orientation update error: \(error.localizedDescription)")
if orientationMask == .all {
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .all)) { error in
logger.error("Orientation update error: \(error.localizedDescription)")
DispatchQueue.main.async {
UIViewController.attemptRotationToDeviceOrientation()
}
}
} else {
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: orientationMask)) { error in
logger.error("Orientation update error: \(error.localizedDescription)")
}
}
}
} else {
UIDevice.current.setValue(orientation.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
}
private func setupOrientationObserver() {
orientationObserver = NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self = self, self.isOrientationLocked else { return }
if self.currentOrientationMask == .portrait {
self.forceOrientation(.portrait)
} else if self.currentOrientationMask == .landscape {
self.forceOrientation(.landscapeRight)
if orientationMask == .all {
UIViewController.attemptRotationToDeviceOrientation()
} else {
let specificOrientation: UIInterfaceOrientation
if orientationMask.contains(.portrait) {
specificOrientation = .portrait
} else if orientationMask.contains(.landscape) {
let currentOrientation = UIDevice.current.orientation
if currentOrientation == .landscapeLeft {
specificOrientation = .landscapeRight
} else if currentOrientation == .landscapeRight {
specificOrientation = .landscapeLeft
} else {
specificOrientation = .landscapeRight
}
} else {
specificOrientation = .portrait
}
UIDevice.current.setValue(specificOrientation.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
}
}
@@ -431,3 +435,66 @@ extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
return UIApplication.shared.windows.first ?? UIWindow()
}
}
extension NativeBridgePlugin: UIApplicationDelegate {
public func application(
_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?
) -> UIInterfaceOrientationMask {
return self.currentOrientationMask
}
/*
Proxy all application delegate methods to the original delegate:
sel!(application:didFinishLaunchingWithOptions:),
sel!(application:openURL:options:),
sel!(application:continue:restorationHandler:),
sel!(applicationDidBecomeActive:),
sel!(applicationWillResignActive:),
sel!(applicationWillEnterForeground:),
sel!(applicationDidEnterBackground:),
sel!(applicationWillTerminate:),
*/
public func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
self.originalDelegate?.application?(application, didFinishLaunchingWithOptions: launchOptions)
?? false
}
public func application(
_ application: UIApplication, open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
self.originalDelegate?.application?(application, open: url, options: options) ?? false
}
public func application(
_ application: UIApplication, continue continueUserActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
self.originalDelegate?.application?(
application, continue: continueUserActivity, restorationHandler: restorationHandler) ?? false
}
public func applicationDidBecomeActive(_ application: UIApplication) {
self.originalDelegate?.applicationDidBecomeActive?(application)
}
public func applicationWillResignActive(_ application: UIApplication) {
self.originalDelegate?.applicationWillResignActive?(application)
}
public func applicationWillEnterForeground(_ application: UIApplication) {
self.originalDelegate?.applicationWillEnterForeground?(application)
}
public func applicationDidEnterBackground(_ application: UIApplication) {
self.originalDelegate?.applicationDidEnterBackground?(application)
}
public func applicationWillTerminate(_ application: UIApplication) {
self.originalDelegate?.applicationWillTerminate?(application)
}
}
@@ -0,0 +1,17 @@
/.vs
.DS_Store
.Thumbs.db
*.sublime*
.idea/
debug.log
package-lock.json
.vscode/settings.json
yarn.lock
/.tauri
/target
Cargo.lock
node_modules/
dist-js
dist
@@ -0,0 +1,19 @@
[package]
name = "tauri-plugin-native-tts"
version = "0.1.0"
authors = [ "You" ]
description = ""
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-native-tts"
[dependencies]
tauri = { version = "2.5.0" }
serde = "1.0"
thiserror = "2"
schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.2.0", features = ["build"] }
schemars = "0.8"
@@ -0,0 +1 @@
# Tauri Plugin native-tts
@@ -0,0 +1,2 @@
/build
/.tauri
@@ -0,0 +1,44 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.readest.native_tts"
compileSdk = 34
defaultConfig {
minSdk = 21
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
implementation(project(":tauri-android"))
}
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,31 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
google()
}
resolutionStrategy {
eachPlugin {
switch (requested.id.id) {
case "com.android.library":
useVersion("8.0.2")
break
case "org.jetbrains.kotlin.android":
useVersion("1.8.20")
break
}
}
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
google()
}
}
include ':tauri-android'
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
@@ -0,0 +1,24 @@
package com.readest.native_tts
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.readest.native_tts", appContext.packageName)
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,385 @@
package com.readest.native_tts
import android.os.Bundle
import android.app.Activity
import android.content.Context
import android.provider.Settings
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.speech.tts.Voice
import android.util.Log
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.PluginResult
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.*
data class TTSVoiceData(
val id: String,
val name: String,
val lang: String,
val disabled: Boolean = false
)
data class TTSMessageEvent(
val code: String, // 'boundary' | 'error' | 'end'
val message: String? = null,
val mark: String? = null
)
@InvokeArg
class SpeakArgs(
val text: String? = "",
val preload: Boolean? = false
)
@InvokeArg
class SetRateArgs(
val rate: Float? = 1.0f
)
@InvokeArg
class SetPitchArgs(
val pitch: Float? = 1.0f
)
@InvokeArg
class SetVoiceArgs(
val voice: String? = null
)
@TauriPlugin
class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val TAG = "NativeTTSPlugin"
private const val CHANNEL_NAME = "tts_events"
}
private var textToSpeech: TextToSpeech? = null
private var isInitialized = AtomicBoolean(false)
private var isPaused = AtomicBoolean(false)
private var isSpeaking = AtomicBoolean(false)
private var currentRate = AtomicReference<Float>(1.0f)
private var currentPitch = AtomicReference<Float>(1.0f)
private val eventChannels = ConcurrentHashMap<String, Channel<TTSMessageEvent>>()
private val speakingJobs = ConcurrentHashMap<String, Job>()
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@Command
fun init(invoke: Invoke) {
coroutineScope.launch {
try {
val success = initializeTTS()
val result = JSObject().apply {
put("success", success)
}
invoke.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize TTS", e)
invoke.reject("Failed to initialize TTS: ${e.message}")
}
}
}
private suspend fun initializeTTS(): Boolean = suspendCancellableCoroutine { continuation ->
try {
val preferredEngine = Settings.Secure.getString(
activity.contentResolver,
Settings.Secure.TTS_DEFAULT_SYNTH
)
textToSpeech = TextToSpeech(activity, { status ->
when (status) {
TextToSpeech.SUCCESS -> {
setupTTSListener()
isInitialized.set(true)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
continuation.resume(true) {}
}
else -> {
Log.e(TAG, "TTS initialization failed with status: $status")
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
continuation.resume(false) {}
}
}
}, preferredEngine)
} catch (e: Exception) {
Log.e(TAG, "Exception during TTS initialization", e)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
continuation.resume(false) {}
}
}
private fun setupTTSListener() {
textToSpeech?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
utteranceId?.let { id ->
isSpeaking.set(true)
sendEvent(id, TTSMessageEvent("boundary", "start"))
}
}
override fun onDone(utteranceId: String?) {
utteranceId?.let { id ->
isSpeaking.set(false)
sendEvent(id, TTSMessageEvent("end"))
closeEventChannel(id)
}
}
@Deprecated("deprecated in API level 21")
override fun onError(utteranceId: String?) {
utteranceId?.let { id ->
isSpeaking.set(false)
sendEvent(id, TTSMessageEvent("error", "TTS playback error"))
closeEventChannel(id)
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
utteranceId?.let { id ->
isSpeaking.set(false)
sendEvent(id, TTSMessageEvent("error", "TTS playback error:$errorCode"))
closeEventChannel(id)
}
}
override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
utteranceId?.let { id ->
sendEvent(id, TTSMessageEvent("boundary", "range", "pos:$start-$end"))
}
}
})
}
@Command
fun speak(invoke: Invoke) {
val args = invoke.parseArgs(SpeakArgs::class.java)
val text = args.text ?: ""
if (text.isEmpty()) {
invoke.reject("Text cannot be empty")
return
}
val utteranceId = UUID.randomUUID().toString()
coroutineScope.launch {
try {
val eventChannel = Channel<TTSMessageEvent>(Channel.UNLIMITED)
eventChannels[utteranceId] = eventChannel
val speakJob = launch {
speakText(text, utteranceId, args.preload ?: false)
}
speakingJobs[utteranceId] = speakJob
val result = JSObject().apply {
put("utteranceId", utteranceId)
}
invoke.resolve(result)
// Start sending events to the frontend
startEventStream(utteranceId)
} catch (e: Exception) {
Log.e(TAG, "Failed to start speaking", e)
invoke.reject("Failed to start speaking: ${e.message}")
}
}
}
private suspend fun speakText(text: String, utteranceId: String, preload: Boolean) {
withContext(Dispatchers.Main) {
try {
textToSpeech?.apply {
setSpeechRate(currentRate.get())
setPitch(currentPitch.get())
}
val params = Bundle().apply {
putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId)
}
val result = textToSpeech?.speak(
text,
if (preload) TextToSpeech.QUEUE_ADD else TextToSpeech.QUEUE_FLUSH,
params,
utteranceId
)
if (result != TextToSpeech.SUCCESS) {
sendEvent(utteranceId, TTSMessageEvent("error", "Failed to start speech"))
}
} catch (e: Exception) {
sendEvent(utteranceId, TTSMessageEvent("error", "Exception during speech: ${e.message}"))
}
}
}
private fun startEventStream(utteranceId: String) {
coroutineScope.launch {
val channel = eventChannels[utteranceId] ?: return@launch
try {
for (event in channel) {
val eventData = JSObject().apply {
put("utteranceId", utteranceId)
put("code", event.code)
event.message?.let { put("message", it) }
event.mark?.let { put("mark", it) }
}
trigger(CHANNEL_NAME, eventData)
}
} catch (e: Exception) {
Log.e(TAG, "Error in event stream for $utteranceId", e)
}
}
}
private fun sendEvent(utteranceId: String, event: TTSMessageEvent) {
coroutineScope.launch {
eventChannels[utteranceId]?.trySend(event)
}
}
private fun closeEventChannel(utteranceId: String) {
coroutineScope.launch {
eventChannels[utteranceId]?.close()
eventChannels.remove(utteranceId)
speakingJobs[utteranceId]?.cancel()
speakingJobs.remove(utteranceId)
}
}
@Command
fun pause(invoke: Invoke) {
try {
if (textToSpeech?.stop() == TextToSpeech.SUCCESS) {
isPaused.set(true)
invoke.resolve()
} else {
invoke.reject("Failed to pause TTS")
}
} catch (e: Exception) {
invoke.reject("Exception while pausing: ${e.message}")
}
}
@Command
fun resume(invoke: Invoke) {
try {
isPaused.set(false)
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Exception while resuming: ${e.message}")
}
}
@Command
fun stop(invoke: Invoke) {
try {
if (textToSpeech?.stop() == TextToSpeech.SUCCESS) {
isSpeaking.set(false)
isPaused.set(false)
speakingJobs.values.forEach { it.cancel() }
eventChannels.values.forEach { it.close() }
speakingJobs.clear()
eventChannels.clear()
invoke.resolve()
} else {
invoke.reject("Failed to stop TTS")
}
} catch (e: Exception) {
invoke.reject("Exception while stopping: ${e.message}")
}
}
@Command
fun set_rate(invoke: Invoke) {
val args = invoke.parseArgs(SetRateArgs::class.java)
try {
currentRate.set(args.rate)
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Exception setting rate: ${e.message}")
}
}
@Command
fun set_pitch(invoke: Invoke) {
val args = invoke.parseArgs(SetPitchArgs::class.java)
try {
currentPitch.set(args.pitch)
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Exception setting pitch: ${e.message}")
}
}
@Command
fun set_voice(invoke: Invoke) {
val args = invoke.parseArgs(SetVoiceArgs::class.java)
try {
val voices = textToSpeech?.voices
val targetVoice = voices?.find { it.name == args.voice }
if (targetVoice != null) {
val result = textToSpeech?.setVoice(targetVoice)
if (result == TextToSpeech.SUCCESS) {
invoke.resolve()
} else {
invoke.reject("Failed to set voice: ${args.voice}")
}
} else {
invoke.reject("Voice not found: ${args.voice}")
}
} catch (e: Exception) {
invoke.reject("Exception setting voice: ${e.message}")
}
}
@Command
fun get_all_voices(invoke: Invoke) {
try {
val voices = textToSpeech?.voices?.map { voice ->
JSObject().apply {
put("id", voice.name)
put("name", voice.name)
put("lang", voice.locale.toLanguageTag())
put("disabled", false)
}
} ?: emptyList()
val result = JSObject().apply {
put("voices", JSONArray(voices))
}
invoke.resolve(result)
} catch (e: Exception) {
invoke.reject("Exception getting voices: ${e.message}")
}
}
fun destroy() {
coroutineScope.cancel()
textToSpeech?.shutdown()
eventChannels.values.forEach { it.close() }
eventChannels.clear()
speakingJobs.values.forEach { it.cancel() }
speakingJobs.clear()
}
}
@@ -0,0 +1,17 @@
package com.readest.native-tts
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,20 @@
const COMMANDS: &[&str] = &[
"init",
"speak",
"stop",
"pause",
"resume",
"set_rate",
"set_pitch",
"set_voice",
"get_all_voices",
"registerListener",
"remove_listener",
];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -0,0 +1,10 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved
@@ -0,0 +1,32 @@
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "tauri-plugin-native-tts",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-tts",
type: .static,
targets: ["tauri-plugin-native-tts"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-tts",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
)
@@ -0,0 +1,3 @@
# Tauri Plugin native-tts
A description of this package.
@@ -0,0 +1,20 @@
import SwiftRs
import Tauri
import UIKit
import WebKit
class PingArgs: Decodable {
let value: String?
}
class NativeTTSPlugin: Plugin {
@objc public func ping(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(PingArgs.self)
invoke.resolve(["value": args.value ?? ""])
}
}
@_cdecl("init_plugin_native_tts")
func initPlugin() -> Plugin {
return NativeTTSPlugin()
}
@@ -0,0 +1,8 @@
import XCTest
@testable import ExamplePlugin
final class ExamplePluginTests: XCTestCase {
func testExample() throws {
let plugin = ExamplePlugin()
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-all-voices"
description = "Enables the get_all_voices command without any pre-configured scope."
commands.allow = ["get_all_voices"]
[[permission]]
identifier = "deny-get-all-voices"
description = "Denies the get_all_voices command without any pre-configured scope."
commands.deny = ["get_all_voices"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-init"
description = "Enables the init command without any pre-configured scope."
commands.allow = ["init"]
[[permission]]
identifier = "deny-init"
description = "Denies the init command without any pre-configured scope."
commands.deny = ["init"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-pause"
description = "Enables the pause command without any pre-configured scope."
commands.allow = ["pause"]
[[permission]]
identifier = "deny-pause"
description = "Denies the pause command without any pre-configured scope."
commands.deny = ["pause"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-registerListener"
description = "Enables the registerListener command without any pre-configured scope."
commands.allow = ["registerListener"]
[[permission]]
identifier = "deny-registerListener"
description = "Denies the registerListener command without any pre-configured scope."
commands.deny = ["registerListener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-remove-listener"
description = "Enables the remove_listener command without any pre-configured scope."
commands.allow = ["remove_listener"]
[[permission]]
identifier = "deny-remove-listener"
description = "Denies the remove_listener command without any pre-configured scope."
commands.deny = ["remove_listener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-resume"
description = "Enables the resume command without any pre-configured scope."
commands.allow = ["resume"]
[[permission]]
identifier = "deny-resume"
description = "Denies the resume command without any pre-configured scope."
commands.deny = ["resume"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-pitch"
description = "Enables the set_pitch command without any pre-configured scope."
commands.allow = ["set_pitch"]
[[permission]]
identifier = "deny-set-pitch"
description = "Denies the set_pitch command without any pre-configured scope."
commands.deny = ["set_pitch"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-rate"
description = "Enables the set_rate command without any pre-configured scope."
commands.allow = ["set_rate"]
[[permission]]
identifier = "deny-set-rate"
description = "Denies the set_rate command without any pre-configured scope."
commands.deny = ["set_rate"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-voice"
description = "Enables the set_voice command without any pre-configured scope."
commands.allow = ["set_voice"]
[[permission]]
identifier = "deny-set-voice"
description = "Denies the set_voice command without any pre-configured scope."
commands.deny = ["set_voice"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-speak"
description = "Enables the speak command without any pre-configured scope."
commands.allow = ["speak"]
[[permission]]
identifier = "deny-speak"
description = "Denies the speak command without any pre-configured scope."
commands.deny = ["speak"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-stop"
description = "Enables the stop command without any pre-configured scope."
commands.allow = ["stop"]
[[permission]]
identifier = "deny-stop"
description = "Denies the stop command without any pre-configured scope."
commands.deny = ["stop"]
@@ -0,0 +1,313 @@
## Default Permission
Default permissions for the plugin
#### This default permission set includes the following:
- `allow-init`
- `allow-speak`
- `allow-stop`
- `allow-pause`
- `allow-resume`
- `allow-set-rate`
- `allow-set-pitch`
- `allow-set-voice`
- `allow-get-all-voices`
- `allow-registerListener`
- `allow-remove-listener`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`native-tts:allow-get-all-voices`
</td>
<td>
Enables the get_all_voices command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-get-all-voices`
</td>
<td>
Denies the get_all_voices command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-init`
</td>
<td>
Enables the init command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-init`
</td>
<td>
Denies the init command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-pause`
</td>
<td>
Enables the pause command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-pause`
</td>
<td>
Denies the pause command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-registerListener`
</td>
<td>
Enables the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-registerListener`
</td>
<td>
Denies the registerListener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-remove-listener`
</td>
<td>
Enables the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-remove-listener`
</td>
<td>
Denies the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-resume`
</td>
<td>
Enables the resume command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-resume`
</td>
<td>
Denies the resume command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-set-pitch`
</td>
<td>
Enables the set_pitch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-set-pitch`
</td>
<td>
Denies the set_pitch command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-set-rate`
</td>
<td>
Enables the set_rate command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-set-rate`
</td>
<td>
Denies the set_rate command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-set-voice`
</td>
<td>
Enables the set_voice command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-set-voice`
</td>
<td>
Denies the set_voice command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-speak`
</td>
<td>
Enables the speak command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-speak`
</td>
<td>
Denies the speak command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-stop`
</td>
<td>
Enables the stop command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-stop`
</td>
<td>
Denies the stop command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,15 @@
[default]
description = "Default permissions for the plugin"
permissions = [
"allow-init",
"allow-speak",
"allow-stop",
"allow-pause",
"allow-resume",
"allow-set-rate",
"allow-set-pitch",
"allow-set-voice",
"allow-get-all-voices",
"allow-registerListener",
"allow-remove-listener",
]
@@ -0,0 +1,438 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the get_all_voices command without any pre-configured scope.",
"type": "string",
"const": "allow-get-all-voices",
"markdownDescription": "Enables the get_all_voices command without any pre-configured scope."
},
{
"description": "Denies the get_all_voices command without any pre-configured scope.",
"type": "string",
"const": "deny-get-all-voices",
"markdownDescription": "Denies the get_all_voices command without any pre-configured scope."
},
{
"description": "Enables the init command without any pre-configured scope.",
"type": "string",
"const": "allow-init",
"markdownDescription": "Enables the init command without any pre-configured scope."
},
{
"description": "Denies the init command without any pre-configured scope.",
"type": "string",
"const": "deny-init",
"markdownDescription": "Denies the init command without any pre-configured scope."
},
{
"description": "Enables the pause command without any pre-configured scope.",
"type": "string",
"const": "allow-pause",
"markdownDescription": "Enables the pause command without any pre-configured scope."
},
{
"description": "Denies the pause command without any pre-configured scope.",
"type": "string",
"const": "deny-pause",
"markdownDescription": "Denies the pause command without any pre-configured scope."
},
{
"description": "Enables the registerListener command without any pre-configured scope.",
"type": "string",
"const": "allow-registerListener",
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
},
{
"description": "Denies the registerListener command without any pre-configured scope.",
"type": "string",
"const": "deny-registerListener",
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the resume command without any pre-configured scope.",
"type": "string",
"const": "allow-resume",
"markdownDescription": "Enables the resume command without any pre-configured scope."
},
{
"description": "Denies the resume command without any pre-configured scope.",
"type": "string",
"const": "deny-resume",
"markdownDescription": "Denies the resume command without any pre-configured scope."
},
{
"description": "Enables the set_pitch command without any pre-configured scope.",
"type": "string",
"const": "allow-set-pitch",
"markdownDescription": "Enables the set_pitch command without any pre-configured scope."
},
{
"description": "Denies the set_pitch command without any pre-configured scope.",
"type": "string",
"const": "deny-set-pitch",
"markdownDescription": "Denies the set_pitch command without any pre-configured scope."
},
{
"description": "Enables the set_rate command without any pre-configured scope.",
"type": "string",
"const": "allow-set-rate",
"markdownDescription": "Enables the set_rate command without any pre-configured scope."
},
{
"description": "Denies the set_rate command without any pre-configured scope.",
"type": "string",
"const": "deny-set-rate",
"markdownDescription": "Denies the set_rate command without any pre-configured scope."
},
{
"description": "Enables the set_voice command without any pre-configured scope.",
"type": "string",
"const": "allow-set-voice",
"markdownDescription": "Enables the set_voice command without any pre-configured scope."
},
{
"description": "Denies the set_voice command without any pre-configured scope.",
"type": "string",
"const": "deny-set-voice",
"markdownDescription": "Denies the set_voice command without any pre-configured scope."
},
{
"description": "Enables the speak command without any pre-configured scope.",
"type": "string",
"const": "allow-speak",
"markdownDescription": "Enables the speak command without any pre-configured scope."
},
{
"description": "Denies the speak command without any pre-configured scope.",
"type": "string",
"const": "deny-speak",
"markdownDescription": "Denies the speak command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-registerListener`\n- `allow-remove-listener`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-registerListener`\n- `allow-remove-listener`"
}
]
}
}
}
@@ -0,0 +1,53 @@
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::NativeTtsExt;
use crate::Result;
#[command]
pub(crate) async fn init<R: Runtime>(app: AppHandle<R>) -> Result<InitResponse> {
app.native_tts().init()
}
#[command]
pub(crate) async fn speak<R: Runtime>(
app: AppHandle<R>,
payload: SpeakArgs,
) -> Result<SpeakResponse> {
app.native_tts().speak(payload)
}
#[command]
pub(crate) async fn pause<R: Runtime>(app: AppHandle<R>) -> Result<()> {
app.native_tts().pause()
}
#[command]
pub(crate) async fn resume<R: Runtime>(app: AppHandle<R>) -> Result<()> {
app.native_tts().resume()
}
#[command]
pub(crate) async fn stop<R: Runtime>(app: AppHandle<R>) -> Result<()> {
app.native_tts().stop()
}
#[command]
pub(crate) async fn set_rate<R: Runtime>(app: AppHandle<R>, payload: SetRateArgs) -> Result<()> {
app.native_tts().set_rate(payload)
}
#[command]
pub(crate) async fn set_pitch<R: Runtime>(app: AppHandle<R>, payload: SetPitchArgs) -> Result<()> {
app.native_tts().set_pitch(payload)
}
#[command]
pub(crate) async fn set_voice<R: Runtime>(app: AppHandle<R>, payload: SetVoiceArgs) -> Result<()> {
app.native_tts().set_voice(payload)
}
#[command]
pub(crate) async fn get_all_voices<R: Runtime>(app: AppHandle<R>) -> Result<GetVoicesResponse> {
app.native_tts().get_all_voices()
}
@@ -0,0 +1,44 @@
use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<NativeTts<R>> {
Ok(NativeTts(app.clone()))
}
/// Access to the native-tts APIs.
pub struct NativeTts<R: Runtime>(AppHandle<R>);
impl<R: Runtime> NativeTts<R> {
pub fn init(&self) -> crate::Result<InitResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn speak(&self, _args: SpeakArgs) -> crate::Result<SpeakResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn pause(&self) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn resume(&self) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn stop(&self) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_rate(&self, _args: SetRateArgs) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_pitch(&self, _args: SetPitchArgs) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_voice(&self, _args: SetVoiceArgs) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_all_voices(&self) -> crate::Result<GetVoicesResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -0,0 +1,25 @@
use serde::{ser::Serializer, Serialize};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Unsupported platform for this plugin")]
UnsupportedPlatformError,
#[error("Native tts error: {0}")]
NativeTTSError(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[cfg(mobile)]
#[error(transparent)]
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
@@ -0,0 +1,58 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use models::*;
#[cfg(desktop)]
mod desktop;
#[cfg(mobile)]
mod mobile;
mod commands;
mod error;
mod models;
pub use error::{Error, Result};
#[cfg(desktop)]
use desktop::NativeTts;
#[cfg(mobile)]
use mobile::NativeTts;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the native-tts APIs.
pub trait NativeTtsExt<R: Runtime> {
fn native_tts(&self) -> &NativeTts<R>;
}
impl<R: Runtime, T: Manager<R>> crate::NativeTtsExt<R> for T {
fn native_tts(&self) -> &NativeTts<R> {
self.state::<NativeTts<R>>().inner()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("native-tts")
.invoke_handler(tauri::generate_handler![
commands::init,
commands::speak,
commands::stop,
commands::pause,
commands::resume,
commands::set_rate,
commands::set_pitch,
commands::set_voice,
commands::get_all_voices,
])
.setup(|app, api| {
#[cfg(mobile)]
let native_tts = mobile::init(app, api)?;
#[cfg(desktop)]
let native_tts = desktop::init(app, api)?;
app.manage(native_tts);
Ok(())
})
.build()
}
@@ -0,0 +1,89 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_native_tts);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<NativeTts<R>> {
#[cfg(target_os = "android")]
let handle = api.register_android_plugin("com.readest.native_tts", "NativeTTSPlugin")?;
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_native_tts)?;
Ok(NativeTts(handle))
}
/// Access to the native-tts APIs.
pub struct NativeTts<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> NativeTts<R> {
pub fn init(&self) -> crate::Result<InitResponse> {
self.0.run_mobile_plugin("init", ()).map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn speak(&self, payload: SpeakArgs) -> crate::Result<SpeakResponse> {
self.0
.run_mobile_plugin("speak", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn pause(&self) -> crate::Result<()> {
self.0.run_mobile_plugin("pause", ()).map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn resume(&self) -> crate::Result<()> {
self.0.run_mobile_plugin("resume", ()).map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn stop(&self) -> crate::Result<()> {
self.0.run_mobile_plugin("stop", ()).map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn set_rate(&self, payload: SetRateArgs) -> crate::Result<()> {
self.0
.run_mobile_plugin("set_rate", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn set_pitch(&self, payload: SetPitchArgs) -> crate::Result<()> {
self.0
.run_mobile_plugin("set_pitch", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn set_voice(&self, payload: SetVoiceArgs) -> crate::Result<()> {
self.0
.run_mobile_plugin("set_voice", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn get_all_voices(&self) -> crate::Result<GetVoicesResponse> {
self.0
.run_mobile_plugin("get_all_voices", ())
.map_err(Into::into)
}
}
@@ -0,0 +1,63 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TTSVoice {
pub id: String,
pub name: String,
pub lang: String,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TTSMessageEvent {
pub code: String, // 'boundary' | 'error' | 'end'
pub message: Option<String>,
pub mark: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InitResponse {
pub success: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpeakArgs {
pub text: String,
#[serde(default)]
pub preload: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpeakResponse {
pub utterance_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetRateArgs {
pub rate: f32,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetPitchArgs {
pub pitch: f32,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetVoiceArgs {
pub voice: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetVoicesResponse {
pub voices: Vec<TTSVoice>,
}
+1
View File
@@ -134,6 +134,7 @@ pub fn run() {
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_native_bridge::init())
.plugin(tauri_plugin_native_tts::init())
.plugin(tauri_plugin_fs::init());
#[cfg(desktop)]
+4 -1
View File
@@ -151,7 +151,10 @@
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK",
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
"endpoints": [
"https://download.readest.com/releases/latest.json",
"https://github.com/readest/readest/releases/latest/download/latest.json"
]
}
}
}
+25 -25
View File
@@ -1,42 +1,42 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { handleAuthCallback } from '@/helpers/auth';
import { useAuth } from '@/context/AuthContext';
import { handleAuthCallback } from '@/helpers/auth';
export default function AuthCallback() {
const router = useRouter();
const { login } = useAuth();
if (typeof window === 'undefined') {
return null;
}
const hash = window.location.hash || '';
const params = new URLSearchParams(hash.slice(1));
useEffect(() => {
const hash = window.location.hash || '';
const params = new URLSearchParams(hash.slice(1));
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const type = params.get('type');
const next = params.get('next') ?? '/';
const error = params.get('error');
const errorDescription = params.get('error_description');
const errorCode = params.get('error_code');
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const type = params.get('type');
const next = params.get('next') ?? '/';
const error = params.get('error');
const errorDescription = params.get('error_description');
const errorCode = params.get('error_code');
handleAuthCallback({
accessToken,
refreshToken,
type,
next,
error,
errorCode,
errorDescription,
login,
navigate: router.push,
});
handleAuthCallback({
accessToken,
refreshToken,
type,
next,
error,
errorCode,
errorDescription,
login,
navigate: router.push,
});
}, [login, router]);
return (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<span className='loading loading-infinity loading-xl w-20'></span>
<span className='loading loading-infinity loading-xl w-20' />
</div>
);
}
+2 -3
View File
@@ -18,14 +18,13 @@ import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { isTauriAppPlatform } from '@/services/environment';
import { getBaseUrl, isTauriAppPlatform } from '@/services/environment';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
import { READEST_WEB_BASE_URL } from '@/services/constants';
import WindowButtons from '@/components/WindowButtons';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -42,7 +41,7 @@ interface ProviderLoginProp {
label: string;
}
const WEB_AUTH_CALLBACK = `${READEST_WEB_BASE_URL}/auth/callback`;
const WEB_AUTH_CALLBACK = `${getBaseUrl()}/auth/callback`;
const DEEPLINK_CALLBACK = 'readest://auth-callback';
const USE_APPLE_SIGN_IN = process.env['NEXT_PUBLIC_USE_APPLE_SIGN_IN'] === 'true';
+118
View File
@@ -0,0 +1,118 @@
'use client';
import posthog from 'posthog-js';
import { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { parseWebViewVersion } from '@/utils/ua';
interface ErrorPageProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ error, reset }: ErrorPageProps) {
const _ = useTranslation();
const { appService } = useEnv();
const [browserInfo, setBrowserInfo] = useState('');
useEffect(() => {
setBrowserInfo(parseWebViewVersion(appService));
posthog.captureException(error);
}, [appService, error]);
const handleGoHome = () => {
window.location.href = '/library';
};
const handleGoBack = () => {
if (window.history.length > 1) {
window.history.back();
} else {
handleGoHome();
}
};
return (
<div className='hero bg-base-200 min-h-screen'>
<div className='hero-content text-center'>
<div className='w-full max-w-2xl p-1'>
<div className='mb-8 mt-6'>
<div className='text-error animate-pulse text-8xl'></div>
</div>
<h1 className='text-base-content mb-4 text-5xl font-bold'>Oops!</h1>
<p className='text-base-content/70 mb-8 text-lg'>
{_(
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.",
)}
</p>
<div className='alert alert-error mb-8'>
<div className='flex-col items-start text-left'>
<h3 className='mb-2 font-bold'>{_('Error Details:')}</h3>
<p className='break-words font-mono text-sm'>{error.message}</p>
{browserInfo && <p className='mt-2 font-mono text-sm'>Browser: {browserInfo}</p>}
{error.stack && (
<p className='mt-2 whitespace-pre-wrap break-all font-mono text-sm'>
{error.stack.split('\n').slice(0, 3).join('\n')}
</p>
)}
{error.digest && <p className='mt-2 text-xs opacity-70'>Error ID: {error.digest}</p>}
</div>
</div>
<div className='flex flex-col gap-4'>
<button onClick={reset} className='btn btn-primary btn-lg'>
<svg className='mr-2 h-5 w-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
/>
</svg>
{_('Try Again')}
</button>
<div className='flex gap-3'>
<button onClick={handleGoBack} className='btn btn-outline flex-1'>
<svg className='mr-2 h-4 w-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M10 19l-7-7m0 0l7-7m-7 7h18'
/>
</svg>
{_('Go Back')}
</button>
<button onClick={handleGoHome} className='btn btn-outline flex-1'>
<svg className='mr-2 h-4 w-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'
/>
</svg>
{_('Your Library')}
</button>
</div>
</div>
<div className='border-base-300 mt-8 border-t pt-6'>
<p className='text-base-content/60 text-sm'>
{_('Need help?')}{' '}
<a href='mailto:support@readest.com' className='link link-primary'>
{_('Contact Support')}
</a>
</p>
</div>
</div>
</div>
</div>
);
}
@@ -11,15 +11,16 @@ import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/navigation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryViewModeType } from '@/types/settings';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { navigateToLogin } from '@/utils/nav';
import { formatAuthors } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from '@/components/BookCover';
interface BookItemProps {
mode: LibraryViewModeType;
book: Book;
mode: LibraryViewModeType;
coverFit: LibraryCoverFitType;
isSelectMode: boolean;
selectedBooks: string[];
transferProgress: number | null;
@@ -29,8 +30,9 @@ interface BookItemProps {
}
const BookItem: React.FC<BookItemProps> = ({
mode,
book,
mode,
coverFit,
isSelectMode,
selectedBooks,
transferProgress,
@@ -59,11 +61,11 @@ const BookItem: React.FC<BookItemProps> = ({
>
<div
className={clsx(
'bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md',
'relative flex aspect-[28/41] items-center justify-center',
mode === 'list' && 'min-w-20',
)}
>
<BookCover mode={mode} book={book} />
<BookCover mode={mode} book={book} coverFit={coverFit} className='drop-shadow-md' />
{selectedBooks.includes(book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
@@ -102,6 +104,10 @@ const BookItem: React.FC<BookItemProps> = ({
</div>
<div
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
style={{
height: `${iconSize15}px`,
minHeight: `${iconSize15}px`,
}}
>
{book.progress && <ReadingProgress book={book} />}
<div className='flex items-center justify-center gap-x-2'>
@@ -6,7 +6,7 @@ import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-ico
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
import { LibraryViewModeType } from '@/types/settings';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
@@ -66,14 +66,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [sortOrder, setSortOrder] = useState(
searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc'),
);
const [coverFit, setCoverFit] = useState(searchParams?.get('cover') || settings.libraryCoverFit);
const isImportingBook = useRef(false);
const { setLibrary } = useLibraryStore();
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
useEffect(() => {
if (isImportingBook.current) return;
isImportingBook.current = true;
@@ -93,12 +92,22 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importBookUrl, appService]);
useEffect(() => {
if (navBooksGroup) {
setCurrentBookshelf(navBooksGroup.books);
} else {
setCurrentBookshelf(allBookshelfItems);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryBooks, navBooksGroup]);
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
const view = searchParams?.get('view') || settings.libraryViewMode;
const sort = searchParams?.get('sort') || settings.librarySortBy;
const order = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
const cover = searchParams?.get('cover') || settings.libraryCoverFit;
const params = new URLSearchParams(searchParams?.toString());
if (query) {
params.set('q', query);
@@ -125,6 +134,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
} else {
params.delete('view');
}
setCoverFit(cover);
if (cover === 'crop') {
params.delete('cover');
}
if (sort === 'updated' && order === 'desc' && view === 'grid') {
params.delete('sort');
params.delete('order');
@@ -227,6 +240,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
};
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const filteredBookshelfItems = currentBookshelfItems
.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
@@ -279,9 +293,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
>
{filteredBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
mode={viewMode as LibraryViewModeType}
key={`library-item-${'hash' in item ? item.hash : item.id}-${index}`}
item={item}
mode={viewMode as LibraryViewModeType}
coverFit={coverFit as LibraryCoverFitType}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
setLoading={setLoading}
@@ -10,7 +10,7 @@ import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getLocalBookFilename } from '@/utils/book';
import { LibraryViewModeType } from '@/types/settings';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
@@ -73,6 +73,7 @@ export const generateGroupsList = (items: Book[]): BookGroupType[] => {
interface BookshelfItemProps {
mode: LibraryViewModeType;
item: BookshelfItem;
coverFit: LibraryCoverFitType;
isSelectMode: boolean;
selectedBooks: string[];
transferProgress: number | null;
@@ -88,6 +89,7 @@ interface BookshelfItemProps {
const BookshelfItem: React.FC<BookshelfItemProps> = ({
mode,
item,
coverFit,
isSelectMode,
selectedBooks,
transferProgress,
@@ -114,6 +116,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
if (await appService?.isBookAvailable(book)) {
return true;
}
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
@@ -273,6 +278,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
<BookItem
mode={mode}
book={item}
coverFit={coverFit}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
@@ -11,16 +11,18 @@ import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
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 { throttle } from '@/utils/throttle';
import { debounce } from '@/utils/debounce';
import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
import ImportMenu from './ImportMenu';
import SortMenu from './SortMenu';
import ViewMenu from './ViewMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
@@ -43,7 +45,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { statusBarHeight } = useThemeStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { currentBookshelf } = useLibraryStore();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
@@ -56,14 +59,15 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const headerRef = useRef<HTMLDivElement>(null);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
const insets = useSafeAreaInsets();
useShortcuts({
onToggleSelectMode,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledUpdateQueryParam = useCallback(
throttle((value: string) => {
const debouncedUpdateQueryParam = useCallback(
debounce((value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value) {
params.set('q', value);
@@ -71,14 +75,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
params.delete('q');
}
router.push(`?${params.toString()}`);
}, 1000),
}, 500),
[searchParams],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
throttledUpdateQueryParam(newQuery);
debouncedUpdateQueryParam(newQuery);
};
useEffect(() => {
@@ -95,6 +99,12 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
const currentBooksCount = currentBookshelf.reduce(
(acc, item) => acc + ('books' in item ? item.books.length : 1),
0,
);
if (!insets) return null;
return (
<div
@@ -105,8 +115,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '',
? `max(${insets.top}px, ${systemUIVisible ? statusBarHeight : 0}px)`
: '0px',
}}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
@@ -130,7 +140,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<input
type='text'
value={searchQuery}
placeholder={_('Search Books...')}
placeholder={
currentBooksCount > 1
? _('Search in {{count}} Book(s)...', {
count: currentBooksCount,
})
: _('Search Books...')
}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
@@ -146,7 +162,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
type='button'
onClick={() => {
setSearchQuery('');
throttledUpdateQueryParam('');
debouncedUpdateQueryParam('');
}}
className='pe-1 text-gray-400 hover:text-gray-600'
aria-label={_('Clear Search')}
@@ -212,7 +228,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
>
<SortMenu />
<ViewMenu />
</Dropdown>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
@@ -1,23 +1,25 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { PiUserCircle } from 'react-icons/pi';
import { PiUserCircleCheck } from 'react-icons/pi';
import { MdCheck } from 'react-icons/md';
import { TbSunMoon } from 'react-icons/tb';
import { BiMoon, BiSun } from 'react-icons/bi';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
import { QuotaType } from '@/types/user';
import UserAvatar from '@/components/UserAvatar';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -30,12 +32,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const { user } = useAuth();
const { themeMode, setThemeMode } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
const [isAlwaysShowStatusBar, setIsAlwaysShowStatusBar] = useState(settings.alwaysShowStatusBar);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks);
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
@@ -44,10 +47,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
const iconSize = useResponsiveSize(16);
const { quotas } = useQuotaStats();
const showAboutReadest = () => {
setAboutDialogVisible(true);
setIsDropdownOpen?.(false);
};
const downloadReadest = () => {
window.open(DOWNLOAD_READEST_URL, '_blank');
setIsDropdownOpen?.(false);
@@ -63,6 +69,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const cycleThemeMode = () => {
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
setThemeMode(nextMode);
};
const handleReloadPage = () => {
window.location.reload();
setIsDropdownOpen?.(false);
@@ -82,6 +93,14 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const toggleAlwaysShowStatusBar = () => {
settings.alwaysShowStatusBar = !settings.alwaysShowStatusBar;
setSettings(settings);
saveSettings(envConfig, settings);
setIsAlwaysShowStatusBar(settings.alwaysShowStatusBar);
setIsDropdownOpen?.(false);
};
const toggleAutoUploadBooks = () => {
settings.autoUpload = !settings.autoUpload;
setSettings(settings);
@@ -133,22 +152,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsTelemetryEnabled(settings.telemetryEnabled);
};
useEffect(() => {
if (!user || !token) return;
const storagPlan = getStoragePlanData(token);
const storageQuota: QuotaType = {
name: _('Storage'),
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
}),
used: Math.round(storagPlan.usage / 1024 / 1024),
total: Math.round(storagPlan.quota / 1024 / 1024),
unit: 'MB',
};
setQuotas([storageQuota]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
const userFullName = user?.user_metadata?.['full_name'];
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
@@ -178,7 +181,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
}
>
<ul>
<Quota quotas={quotas} className='h-10 pl-3 pr-2' />
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
</ul>
</MenuItem>
@@ -220,12 +223,30 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleAlwaysOnTop}
/>
)}
{appService?.isMobileApp && (
<MenuItem
label={_('Always Show Status Bar')}
Icon={isAlwaysShowStatusBar ? MdCheck : undefined}
onClick={toggleAlwaysShowStatusBar}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
Icon={isScreenWakeLock ? MdCheck : undefined}
onClick={toggleScreenWakeLock}
/>
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<MenuItem
label={
themeMode === 'dark'
? _('Dark Mode')
: themeMode === 'light'
? _('Light Mode')
: _('Auto Mode')
}
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
<hr className='border-base-200 my-1' />
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
@@ -4,15 +4,15 @@ import { MdCheck } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { LibrarySortByType, LibraryViewModeType } from '@/types/settings';
import { LibraryCoverFitType, LibrarySortByType, LibraryViewModeType } from '@/types/settings';
import { navigateToLibrary } from '@/utils/nav';
import MenuItem from '@/components/MenuItem';
interface SortMenuProps {
interface ViewMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
@@ -22,12 +22,18 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
const viewMode = settings.libraryViewMode;
const sortBy = settings.librarySortBy;
const isAscending = settings.librarySortAscending;
const coverFit = settings.libraryCoverFit;
const viewOptions = [
{ label: _('List'), value: 'list' },
{ label: _('Grid'), value: 'grid' },
];
const coverFitOptions = [
{ label: _('Crop'), value: 'crop' },
{ label: _('Fit'), value: 'fit' },
];
const sortByOptions = [
{ label: _('Title'), value: 'title' },
{ label: _('Author'), value: 'author' },
@@ -52,6 +58,17 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
navigateToLibrary(router, `${params.toString()}`);
};
const handleToggleCropCovers = (value: LibraryCoverFitType) => {
settings.libraryCoverFit = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('cover', value);
navigateToLibrary(router, `${params.toString()}`);
};
const handleSetSortBy = (value: LibrarySortByType) => {
settings.librarySortBy = value;
setSettings(settings);
@@ -89,6 +106,22 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
/>
))}
<hr className='border-base-200 my-1' />
<MenuItem
label={_('Book Covers')}
buttonClass='h-8'
labelClass='text-sm sm:text-xs'
disabled
/>
{coverFitOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
Icon={coverFit === option.value ? MdCheck : undefined}
onClick={() => handleToggleCropCovers(option.value as LibraryCoverFitType)}
/>
))}
<hr className='border-base-200 my-1' />
<MenuItem
label={_('Sort by...')}
buttonClass='h-8'
@@ -118,4 +151,4 @@ const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
);
};
export default SortMenu;
export default ViewMenu;
@@ -1,10 +1,11 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useSync } from '@/hooks/useSync';
import { useLibraryStore } from '@/store/libraryStore';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { Book } from '@/types/book';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { debounce } from '@/utils/debounce';
export interface UseBooksSyncProps {
onSyncStart?: () => void;
@@ -38,9 +39,6 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const lastSyncTime = useRef<number>(0);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const getNewBooks = () => {
if (!user) return [];
const newBooks = library.filter(
@@ -49,26 +47,18 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
return newBooks;
};
useEffect(() => {
if (!user) return;
const now = Date.now();
const timeSinceLastSync = now - lastSyncTime.current;
if (timeSinceLastSync > SYNC_BOOKS_INTERVAL_SEC * 1000) {
lastSyncTime.current = now;
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleAutoSync = useCallback(
debounce(() => {
const newBooks = getNewBooks();
syncBooks(newBooks, 'both');
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastSyncTime.current = Date.now();
const newBooks = getNewBooks();
syncBooks(newBooks, 'both');
syncTimeoutRef.current = null;
},
SYNC_BOOKS_INTERVAL_SEC * 1000 - timeSinceLastSync,
);
}
}, SYNC_BOOKS_INTERVAL_SEC * 1000),
[library, lastSyncedAtBooks],
);
useEffect(() => {
if (!user) return;
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [library]);
+25 -17
View File
@@ -28,11 +28,11 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useTheme } from '@/hooks/useTheme';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useThemeStore } from '@/store/themeStore';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { lockScreenOrientation } from '@/utils/bridge';
import { mountAdditionalFonts } from '@/utils/style';
import { mountAdditionalFonts } from '@/utils/font';
import {
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
@@ -69,8 +69,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
} = useLibraryStore();
const _ = useTranslation();
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
const insets = useSafeAreaInsets();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { statusBarHeight } = useThemeStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -218,6 +218,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageRef.current]);
useEffect(() => {
if (!libraryBooks.some((book) => !book.deletedAt)) {
handleSetSelectMode(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryBooks]);
const processOpenWithFiles = React.useCallback(
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const settings = await appService.loadSettings();
@@ -366,7 +373,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
for (const file of files) {
try {
const book = await appService?.importBook(file, library);
setLibrary(library);
setLibrary([...library]);
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book);
@@ -394,8 +401,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const selectFilesTauri = async () => {
const exts = appService?.isAndroidApp ? [] : SUPPORTED_FILE_EXTS;
const exts = appService?.isMobileApp ? [] : 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);
});
}
// Cannot filter out files on Android since some content providers may not return the file name
return files;
};
@@ -518,11 +531,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
let files;
if (isTauriAppPlatform()) {
if (appService?.isIOSApp) {
files = (await selectFilesWeb()) as [File];
} else {
files = (await selectFilesTauri()) as [string];
}
files = (await selectFilesTauri()) as [string];
} else {
files = (await selectFilesWeb()) as [File];
}
@@ -552,7 +561,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setShowDetailsBook(book);
};
if (!appService) {
if (!appService || !insets) {
return null;
}
@@ -575,7 +584,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<div className='fixed top-0 z-40 w-full'>
<div className='top-0 z-40 w-full'>
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
@@ -596,14 +605,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
ref={containerRef}
className={clsx(
'scroll-container drop-zone flex-grow overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[52px]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '48px',
paddingTop: '0px',
paddingRight: `${insets.right}px`,
paddingBottom: `${insets.bottom}px`,
paddingLeft: `${insets.left}px`,
}}
>
<DropIndicator />
@@ -4,21 +4,22 @@ import React, { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
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';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
import FooterBar from './FooterBar';
import PageInfoView from './PageInfo';
import ProgressInfoView from './ProgressInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
import DoubleBorder from './DoubleBorder';
import TTSControl from './tts/TTSControl';
interface BooksGridProps {
bookKeys: string[];
@@ -28,10 +29,14 @@ interface BooksGridProps {
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings, hoveredBookKey } = useReaderStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { getGridInsets, setGridInsets, hoveredBookKey } = useReaderStore();
const { isSideBarVisible, sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
const screenInsets = useSafeAreaInsets();
const aspectRatio = window.innerWidth / window.innerHeight;
const gridTemplate = getGridTemplate(bookKeys.length, aspectRatio);
useEffect(() => {
if (!sideBarBookKey) return;
@@ -41,6 +46,23 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
useEffect(() => {
if (!screenInsets) return;
bookKeys.forEach((bookKey, index) => {
const { top, right, bottom, left } = getInsetEdges(index, bookKeys.length, aspectRatio);
const gridInsets = {
top: top ? screenInsets.top : 0,
right: right ? screenInsets.right : 0,
bottom: bottom ? screenInsets.bottom : 0,
left: left ? screenInsets.left : 0,
};
setGridInsets(bookKey, gridInsets);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKeys, screenInsets]);
if (!screenInsets) return null;
return (
<div
className={clsx('relative grid h-full flex-grow')}
@@ -54,13 +76,20 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const gridInsets = getGridInsets(bookKey);
const { book, bookDoc } = bookData || {};
if (!book || !config || !bookDoc || !viewSettings) return null;
if (!book || !config || !bookDoc || !viewSettings || !gridInsets) return null;
const { section, pageinfo, sectionLabel } = progress || {};
const { section, pageinfo, timeinfo, sectionLabel } = progress || {};
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
const horizontalGapPercent = viewSettings.gapPercent;
const verticalMarginPixels = viewSettings.marginPx;
const viewInsets = getViewInsets(viewSettings);
const contentInsets = {
top: gridInsets.top + viewInsets.top,
right: gridInsets.right + viewInsets.right,
bottom: gridInsets.bottom + viewInsets.bottom,
left: gridInsets.left + viewInsets.left,
};
const scrolled = viewSettings.scrolled;
const showBarsOnScroll = viewSettings.showBarsOnScroll;
const showHeader = viewSettings.showHeader && (scrolled ? showBarsOnScroll : true);
@@ -72,10 +101,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
key={bookKey}
className={clsx(
'relative h-full w-full overflow-hidden',
index === 0 &&
!viewSettings?.scrolled &&
appService?.hasSafeAreaInset &&
'pt-[env(safe-area-inset-top)]',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
)}
>
@@ -87,16 +112,22 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isHoveredAnim={bookKeys.length > 2}
onCloseBook={onCloseBook}
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
gridInsets={gridInsets}
/>
<FoliateViewer
bookKey={bookKey}
bookDoc={bookDoc}
config={config}
contentInsets={contentInsets}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
{viewSettings.vertical && viewSettings.scrolled && (
<>
{(showFooter || viewSettings.doubleBorder) && (
<div
className='bg-base-100 absolute left-0 top-0 h-full'
style={{
width: `calc(${horizontalGapPercent}%)`,
height: `calc(100% - ${verticalMarginPixels}px)`,
width: `calc(${contentInsets.left + (showFooter ? 32 : 0)}px)`,
height: `calc(100%)`,
}}
/>
)}
@@ -104,8 +135,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
className='bg-base-100 absolute right-0 top-0 h-full'
style={{
width: `calc(${horizontalGapPercent}%)`,
height: `calc(100% - ${verticalMarginPixels}px)`,
width: `calc(${contentInsets.right + (showHeader ? 32 : 0)}px)`,
height: `calc(100%)`,
}}
/>
)}
@@ -117,7 +148,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
showFooter={showFooter}
borderColor={viewSettings.borderColor}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
contentInsets={contentInsets}
/>
)}
{showHeader && (
@@ -127,7 +158,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
contentInsets={contentInsets}
gridInsets={gridInsets}
/>
)}
<HintInfo
@@ -136,18 +168,19 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
contentInsets={contentInsets}
gridInsets={gridInsets}
/>
{showFooter && (
<PageInfoView
<ProgressInfoView
bookKey={bookKey}
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
timeinfo={timeinfo}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
contentInsets={contentInsets}
gridInsets={gridInsets}
/>
)}
<Annotator bookKey={bookKey} />
@@ -158,12 +191,12 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
section={section}
pageinfo={pageinfo}
isHoveredAnim={false}
gridInsets={gridInsets}
/>
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
</div>
);
})}
<TTSControl />
</div>
);
};
@@ -1,31 +1,32 @@
import { Insets } from '@/types/misc';
interface DoubleBorderProps {
borderColor: string;
horizontalGap: number;
verticalMargin: number;
showHeader: boolean;
showFooter: boolean;
contentInsets: Insets;
}
const paddingPx = 10;
const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderColor,
horizontalGap,
verticalMargin,
showHeader,
showFooter,
contentInsets,
}) => {
return (
<>
<div>
{/* outter frame */}
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `4px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px + ${paddingPx * 2}px)`,
top: `calc(${verticalMargin}px - ${paddingPx}px)`,
left: `calc(${horizontalGap}% - ${showFooter ? 32 : 0}px - ${paddingPx}px)`,
right: `calc(${horizontalGap}% - ${showHeader ? 32 : 0}px - ${paddingPx}px)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px + ${paddingPx * 2}px)`,
top: `calc(${contentInsets.top}px - ${paddingPx}px)`,
left: `calc(${contentInsets.left}px - ${paddingPx}px)`,
right: `calc(${contentInsets.right}px - ${paddingPx}px)`,
}}
></div>
{/* inner frame */}
@@ -33,10 +34,10 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
className={'borderframe pointer-events-none absolute'}
style={{
border: `1px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: showFooter ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
right: showHeader ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(${contentInsets.left + (showFooter ? 32 : 0)}px`,
right: `calc(${contentInsets.right + (showHeader ? 32 : 0)}px`,
}}
/>
{/* footer */}
@@ -48,9 +49,9 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderBottom: `1px solid ${borderColor}`,
borderLeft: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(${horizontalGap}% - 32px)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(${contentInsets.left}px)`,
}}
/>
)}
@@ -63,13 +64,13 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderBottom: `1px solid ${borderColor}`,
borderRight: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(100% - ${horizontalGap}%)`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(100% - ${contentInsets.right}px - 32px)`,
}}
/>
)}
</>
</div>
);
};
@@ -2,16 +2,19 @@ import React, { useEffect, useRef, useState } from 'react';
import { BookDoc, getDirection } from '@/libs/document';
import { BookConfig } from '@/types/book';
import { FoliateView, wrappedFoliateView } from '@/types/view';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { getStyles, mountAdditionalFonts, transformStylesheet } from '@/utils/style';
import { getStyles, transformStylesheet } from '@/utils/style';
import { mountAdditionalFonts } from '@/utils/font';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
@@ -26,25 +29,39 @@ import {
} from '../utils/iframeEventHandlers';
import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { isCJKLang } from '@/utils/lang';
import { isTauriAppPlatform } from '@/services/environment';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
declare global {
interface Window {
eval(script: string): void;
}
}
const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
config: BookConfig;
}> = ({ bookKey, bookDoc, config }) => {
const { appService } = useEnv();
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const isViewCreated = useRef(false);
contentInsets: Insets;
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const { appService } = useEnv();
const { themeCode, isDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey);
const viewRef = useRef<FoliateView | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isViewCreated = useRef(false);
const [toastMessage, setToastMessage] = useState('');
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
@@ -57,7 +74,15 @@ const FoliateViewer: React.FC<{
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
setProgress(
bookKey,
detail.cfi,
detail.tocItem,
detail.section,
detail.location,
detail.time,
detail.range,
);
};
const getDocTransformHandler = ({ width, height }: { width: number; height: number }) => {
@@ -67,7 +92,7 @@ const FoliateViewer: React.FC<{
.then((data) => {
const viewSettings = getViewSettings(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(viewSettings, width, height, data);
return transformStylesheet(width, height, data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
@@ -92,6 +117,7 @@ const FoliateViewer: React.FC<{
if (detail.doc) {
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
viewSettings.vertical =
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
viewSettings.rtl =
@@ -101,7 +127,17 @@ const FoliateViewer: React.FC<{
false;
setViewSettings(bookKey, { ...viewSettings });
mountAdditionalFonts(detail.doc);
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
// Inline scripts in tauri platforms are not executed by default
if (viewSettings.allowScript && isTauriAppPlatform()) {
evalInlineScripts(detail.doc);
}
// only call on load if we have highlighting turned on.
if (viewSettings.codeHighlighting) {
manageSyntaxHighlighting(detail.doc, viewSettings);
}
if (!detail.doc.isEventListenersAdded) {
// listened events in iframes are posted to the main window
@@ -120,6 +156,22 @@ const FoliateViewer: React.FC<{
}
};
const evalInlineScripts = (doc: Document) => {
if (doc.defaultView && doc.defaultView.frameElement) {
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
const scripts = doc.querySelectorAll('script:not([src])');
scripts.forEach((script, index) => {
const scriptContent = script.textContent || script.innerHTML;
try {
console.warn('Evaluating inline scripts in iframe');
iframe.contentWindow?.eval(scriptContent);
} catch (error) {
console.error(`Error executing iframe script ${index + 1}:`, error);
}
});
}
};
const docRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
@@ -147,14 +199,6 @@ const FoliateViewer: React.FC<{
onRendererRelocate: docRelocateHandler,
});
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode]);
useEffect(() => {
if (isViewCreated.current) return;
isViewCreated.current = true;
@@ -167,10 +211,6 @@ const FoliateViewer: React.FC<{
document.body.append(view);
containerRef.current?.appendChild(view);
const containerRect = containerRef.current?.getBoundingClientRect();
const width = containerRect?.width || window.innerWidth;
const height = containerRect?.height || window.innerHeight;
const viewSettings = getViewSettings(bookKey)!;
const writingMode = viewSettings.writingMode;
if (writingMode) {
@@ -190,15 +230,19 @@ const FoliateViewer: React.FC<{
const { book } = view;
book.transformTarget?.addEventListener('load', (event: Event) => {
const { detail } = event as CustomEvent;
if (detail.isScript) {
detail.allowScript = viewSettings.allowScript ?? false;
}
});
const viewWidth = appService?.isMobile ? screen.width : window.innerWidth;
const viewHeight = appService?.isMobile ? screen.height : window.innerHeight;
const width = viewWidth - insets.left - insets.right;
const height = viewHeight - insets.top - insets.bottom;
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
const isScrolled = viewSettings.scrolled!;
const showHeader = viewSettings.showHeader!;
const showFooter = viewSettings.showFooter!;
const isCompact = !showHeader && !showFooter;
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
const gapPercent = isCompact ? viewSettings.compactGapPercent : viewSettings.gapPercent;
const animated = viewSettings.animated!;
const maxColumnCount = viewSettings.maxColumnCount!;
const maxInlineSize = getMaxInlineSize(viewSettings);
@@ -212,12 +256,10 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
view.renderer.setAttribute('flow', isScrolled ? 'scrolled' : 'paginated');
view.renderer.setAttribute('margin', `${marginPx}px`);
view.renderer.setAttribute('gap', `${gapPercent}%`);
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;
if (lastLocation) {
@@ -231,15 +273,63 @@ const FoliateViewer: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const applyMarginAndGap = () => {
const viewSettings = getViewSettings(bookKey)!;
const viewInsets = getViewInsets(viewSettings);
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
const showDoubleBorderHeader = showDoubleBorder && viewSettings.showHeader;
const showDoubleBorderFooter = showDoubleBorder && viewSettings.showFooter;
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 moreRightInset = showDoubleBorderHeader ? 32 : 0;
const moreLeftInset = showDoubleBorderFooter ? 32 : 0;
const topMargin = (showTopHeader ? insets.top : viewInsets.top) + moreTopInset;
const rightMargin = insets.right + moreRightInset;
const bottomMargin = (showBottomFooter ? insets.bottom : viewInsets.bottom) + moreBottomInset;
const leftMargin = insets.left + moreLeftInset;
viewRef.current?.renderer.setAttribute('margin-top', `${topMargin}px`);
viewRef.current?.renderer.setAttribute('margin-right', `${rightMargin}px`);
viewRef.current?.renderer.setAttribute('margin-bottom', `${bottomMargin}px`);
viewRef.current?.renderer.setAttribute('margin-left', `${leftMargin}px`);
viewRef.current?.renderer.setAttribute('gap', `${viewSettings.gapPercent}%`);
if (viewSettings.scrolled) {
viewRef.current?.renderer.setAttribute('flow', 'scrolled');
}
};
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode]);
useEffect(() => {
if (viewRef.current && viewRef.current.renderer && viewSettings) {
applyMarginAndGap();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
insets.top,
insets.right,
insets.bottom,
insets.left,
viewSettings?.doubleBorder,
viewSettings?.showHeader,
viewSettings?.showFooter,
]);
return (
<>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
</>
<div
ref={containerRef}
className='foliate-viewer h-[100%] w-[100%]'
{...mouseHandlers}
{...touchHandlers}
/>
);
};
@@ -21,8 +21,10 @@ import { eventDispatcher } from '@/utils/event';
import { viewPagination } from '../hooks/usePagination';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { PageInfo } from '@/types/book';
import { Insets } from '@/types/misc';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
import TTSControl from './tts/TTSControl';
interface FooterBarProps {
bookKey: string;
@@ -30,6 +32,7 @@ interface FooterBarProps {
section?: PageInfo;
pageinfo?: PageInfo;
isHoveredAnim: boolean;
gridInsets: Insets;
}
const FooterBar: React.FC<FooterBarProps> = ({
@@ -38,11 +41,12 @@ const FooterBar: React.FC<FooterBarProps> = ({
section,
pageinfo,
isHoveredAnim,
gridInsets,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = React.useState('');
const sliderHeight = useResponsiveSize(28);
@@ -53,6 +57,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
const handleProgressChange = (value: number) => {
view?.goToFraction(value / 100.0);
@@ -63,9 +68,13 @@ const FooterBar: React.FC<FooterBarProps> = ({
};
const handleMarginChange = (value: number) => {
const viewSettings = getViewSettings(bookKey)!;
const marginPx = Math.round((value / 100) * 88);
const gapPercent = Math.round((value / 100) * 10);
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx, false, false);
viewSettings.marginTopPx = marginPx;
viewSettings.marginBottomPx = marginPx / 2;
viewSettings.marginLeftPx = marginPx / 2;
viewSettings.marginRightPx = marginPx / 2;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
@@ -107,12 +116,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
};
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
if (!view || !progress || !viewState) return;
if (viewState.ttsEnabled) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey, range });
eventDispatcher.dispatch('tts-speak', { bookKey });
}
};
@@ -147,6 +155,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
};
const isVisible = hoveredBookKey === bookKey;
const ttsEnabled = viewState?.ttsEnabled;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction =
@@ -154,6 +163,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
? (progressInfo!.current + 1) / progressInfo!.total || 0
: 0;
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
return (
<>
<div
@@ -193,9 +204,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom) + 64px)'
: '64px',
bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px',
}}
>
<div className='flex w-full items-center justify-between gap-x-6'>
@@ -249,9 +258,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom) + 64px)'
: '64px',
bottom: isMobile ? `${gridInsets.bottom + 64}px` : '64px',
}}
>
<Slider
@@ -268,7 +275,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
initialValue={getMarginProgressValue(
viewSettings?.marginPx ?? 44,
viewSettings?.marginTopPx ?? 44,
viewSettings?.gapPercent ?? 5,
)}
bubbleElement={<TbBoxMargin size={marginIconSize} />}
@@ -291,8 +298,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
<div
className={clsx(
'bg-base-200 z-50 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
style={{
paddingBottom: isMobile ? `${gridInsets.bottom + 16}px` : '0px',
}}
>
<Button
icon={<TOCIcon size={tocIconSize} className='' />}
@@ -312,10 +321,18 @@ const FooterBar: React.FC<FooterBarProps> = ({
}
onClick={() => handleSetActionTab('font')}
/>
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
<Button
icon={<TTSIcon className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={() => handleSetActionTab('tts')}
/>
</div>
{/* Desktop footer bar */}
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
{/* Desktop / Pad footer bar */}
<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',
}}
>
<Button
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
@@ -351,7 +368,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={<FaHeadphones className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={handleSpeakText}
tooltip={_('Speak')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
@@ -364,6 +385,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
/>
</div>
</div>
<TTSControl bookKey={bookKey} />
</>
);
};
@@ -2,12 +2,15 @@ import React, { useEffect, useRef, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { getFootnoteStyles, getStyles, getThemeCode, mountAdditionalFonts } from '@/utils/style';
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 { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import { isCJKLang } from '@/utils/lang';
import Popup from '@/components/Popup';
interface FootnotePopupProps {
@@ -26,6 +29,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const [popupPosition, setPopupPosition] = useState<Position | null>();
const [showPopup, setShowPopup] = useState(false);
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
@@ -55,13 +59,17 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
});
view.addEventListener('load', (e: CustomEvent) => {
const { doc } = e.detail;
mountAdditionalFonts(doc);
const bookData = getBookData(bookKey)!;
mountAdditionalFonts(doc, isCJKLang(bookData.book?.primaryLanguage));
});
footnoteViewRef.current = view;
footnoteRef.current?.replaceChildren(view);
const { renderer } = view;
renderer.setAttribute('flow', 'scrolled');
renderer.setAttribute('margin', '0px');
renderer.setAttribute('margin-top', '0px');
renderer.setAttribute('margin-right', '0px');
renderer.setAttribute('margin-bottom', '0px');
renderer.setAttribute('margin-left', '0px');
renderer.setAttribute('gap', '0%');
const viewSettings = getViewSettings(bookKey)!;
const themeCode = getThemeCode();
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
@@ -22,6 +23,7 @@ interface HeaderBarProps {
bookTitle: string;
isTopLeft: boolean;
isHoveredAnim: boolean;
gridInsets: Insets;
onCloseBook: (bookKey: string) => void;
onSetSettingsDialogOpen: (open: boolean) => void;
}
@@ -31,6 +33,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
bookTitle,
isTopLeft,
isHoveredAnim,
gridInsets,
onCloseBook,
onSetSettingsDialogOpen,
}) => {
@@ -77,10 +80,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
return (
<div
className={clsx(
'bg-base-100 absolute top-0 w-full',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
className={clsx('bg-base-100 absolute top-0 w-full')}
style={{
paddingTop: appService?.hasSafeAreaInset ? `${gridInsets.top}px` : '0px',
}}
>
<div
className={clsx('absolute top-0 z-10 hidden h-11 w-full sm:flex')}
@@ -89,11 +92,11 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
/>
<div
className={clsx(
'bg-base-100 absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
'bg-base-100 absolute left-0 right-0 top-0 z-10',
isVisible ? 'visible' : 'hidden',
)}
style={{
height: systemUIVisible ? `max(env(safe-area-inset-top), ${statusBarHeight}px)` : '',
height: systemUIVisible ? `${Math.max(gridInsets.top, statusBarHeight)}px` : '0px',
}}
/>
<div
@@ -110,16 +113,17 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
)}
style={{
marginTop: systemUIVisible
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: 'env(safe-area-inset-top)',
? `${Math.max(gridInsets.top, statusBarHeight)}px`
: `${gridInsets.top}px`,
}}
onMouseLeave={() => !appService?.isMobile && setHoveredBookKey('')}
>
<div className='sidebar-bookmark-toggler z-20 flex h-full items-center gap-x-4'>
<div className='bg-base-100 sidebar-bookmark-toggler z-20 flex h-full items-center gap-x-4 pe-2'>
<div className='hidden sm:flex'>
<SidebarToggler bookKey={bookKey} />
</div>
<BookmarkToggler bookKey={bookKey} />
<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'>
@@ -128,8 +132,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
</h2>
</div>
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4'>
<TranslationToggler bookKey={bookKey} />
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4 ps-2'>
<SettingsToggler />
<NotebookToggler bookKey={bookKey} />
<Dropdown
@@ -1,5 +1,8 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { eventDispatcher } from '@/utils/event';
interface SectionInfoProps {
@@ -8,7 +11,8 @@ interface SectionInfoProps {
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
contentInsets: Insets;
gridInsets: Insets;
}
const HintInfo: React.FC<SectionInfoProps> = ({
@@ -17,8 +21,16 @@ const HintInfo: React.FC<SectionInfoProps> = ({
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
contentInsets,
gridInsets,
}) => {
const { appService } = useEnv();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const topInset = Math.max(
gridInsets.top,
appService?.isAndroidApp && systemUIVisible ? statusBarHeight / 2 : 0,
);
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
const hintTimeout = useRef(2000);
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -50,14 +62,16 @@ const HintInfo: React.FC<SectionInfoProps> = ({
<>
<div
className={clsx(
'absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
'absolute left-0 right-0 top-0 z-10',
hintMessage ? 'bg-base-100' : 'bg-transparent',
)}
style={{
height: `${topInset}px`,
}}
/>
<div
className={clsx(
'hintinfo absolute flex items-center justify-end overflow-hidden ps-2',
'mt-[env(safe-area-inset-top)]',
hintMessage ? 'bg-base-100' : 'bg-transparent',
isVertical ? 'writing-vertical-rl' : 'top-0 h-[44px]',
isScrolled
@@ -71,11 +85,16 @@ const HintInfo: React.FC<SectionInfoProps> = ({
style={
isVertical
? {
bottom: `${verticalMargin * 1.5}px`,
left: `calc(100% - ${horizontalGap}%)`,
bottom: `${contentInsets.bottom * 1.5}px`,
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
}
: { insetInlineEnd: `${horizontalGap}%` }
: {
top: `${topInset}px`,
insetInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
}
}
>
<h2 className={clsx('text-neutral-content text-center font-sans text-xs font-light')}>
@@ -1,73 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { PageInfo } from '@/types/book';
interface PageInfoProps {
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
showDoubleBorder: boolean;
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const PageInfoView: React.FC<PageInfoProps> = ({
bookFormat,
section,
pageinfo,
showDoubleBorder,
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const pageInfo = ['PDF', 'CBZ'].includes(bookFormat)
? section
? isVertical
? `${section.current + 1} · ${section.total}`
: `${section.current + 1} / ${section.total}`
: ''
: pageinfo
? _(isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}', {
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
})
: '';
return (
<div
className={clsx(
'pageinfo absolute bottom-0 flex items-center justify-end',
isVertical ? 'writing-vertical-rl' : 'h-12 w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
style={
isVertical
? {
bottom: `${verticalMargin * 1.5}px`,
left: showDoubleBorder ? `calc(${horizontalGap}% - 32px)` : 0,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: {
insetInlineEnd: `${horizontalGap}%`,
paddingBottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom)*0.67)'
: 0,
}
}
>
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
{pageInfo}
</h2>
</div>
);
};
export default PageInfoView;

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