Compare commits

...

70 Commits

Author SHA1 Message Date
Huang Xin 60a9eb5b57 Release version 0.9.5 (#159) 2025-01-15 17:55:50 +01:00
Egan Gumiwang Pratama Bisma 46fdea3522 feat: provide reading progress indicator for each books in the bookshelf (#153)
* feat: provide reading progress indicator for each books in the bookshelf

* refactor: simplify bookshelf component and remove unused hook; enhance reading progress calculation

- Removed the `useBookConfigLoader` hook as it was no longer needed.
- Simplified the bookshelf items generation by eliminating the use of `useMemo`.
- Improved the reading progress calculation in the `ReadingProgress` component to directly use the book's progress data.
- Updated the `Book` type to include a `progress` field for better tracking of reading status.

* fix formatting for readerStore
2025-01-15 17:17:07 +01:00
Huang Xin ba82203629 Set webpage title to book title (#155) 2025-01-14 17:02:23 +01:00
Huang Xin 8dbcf25331 Toggle TTS stop when it's already running (#154) 2025-01-14 15:32:47 +01:00
Huang Xin 7e9ddcd7cc Preload the next two paragraphs in advance for TTS playback (#151) 2025-01-14 01:45:54 +01:00
Huang Xin 211fc2cd22 Add more system fonts (#150) 2025-01-13 23:27:52 +01:00
Huang Xin 3578d7eba8 Handle publisher with language map in book metadata (#149) 2025-01-13 22:53:58 +01:00
Huang Xin d220adbfdf TTS UX enhancements (#148)
* Fix toast of PDF unsupported for TTS

* Use vertical scroll for large dropdown menus
2025-01-13 20:04:04 +01:00
Huang Xin 752f4449b4 Add fallback fonts for GB18030-2022 L3 charsets (#147)
* Add fallback fonts for GB18030-2022 L3 charsets

* Update README to credit fonts used in this application
2025-01-13 19:14:43 +01:00
Huang Xin 66f369b269 Fix multiple voices when setting rate and voices in Firefox (#146) 2025-01-13 00:19:49 +01:00
Huang Xin 2251d65de4 Release hotfix of occasionally speak not stop when setting rate and voice 2025-01-11 19:08:23 +01:00
Huang Xin 463ea39467 Fix occasionally current speak not stopped when changing voice or rate (#144)
* Fix occasionally current speak not stopped when changing voice or rate

* Inherit tts rate when changing tts engine
2025-01-11 18:11:14 +01:00
Huang Xin 87bc842525 Set default sidebar width to 15% of screen width (#143) 2025-01-11 11:34:43 +01:00
Huang Xin 2ad67a0503 Init TTSController async when showing TTS indicator and add more voices (#142) 2025-01-11 11:15:21 +01:00
Huang Xin 9f894e1d9e Use default voice in Edge TTS (#141) 2025-01-10 15:26:01 +01:00
Huang Xin cc97a1b36f Update README (#140) 2025-01-10 14:36:37 +01:00
Huang Xin 4e42ec4a44 Release 0.9.2 (#139) 2025-01-10 14:13:56 +01:00
Huang Xin 00003a9415 Preloading audio for Edge TTS and other fixes (#138)
* Blacklist some low-quality web tts voices

* Dropdown layout tweaks

* Handle no audio data received in Edge TTS

* Preloading audio for Edge TTS
2025-01-10 14:11:09 +01:00
Huang Xin 7402141237 Add Edge TTS backend, selecting different voices will change TTS backend dynamically (#137) 2025-01-10 02:35:23 +01:00
Huang Xin e0323bb0ed Fix access token sometimes invalid in browser context (#136)
* Update dependencies

* Fix access token sometimes invalid in browser context
2025-01-09 17:58:55 +01:00
Egan Gumiwang Pratama Bisma 112c5fa979 enforce numeric inputs on NumberInput and prevent NaN text (#135) 2025-01-09 12:51:06 +01:00
Egan Gumiwang Pratama Bisma 86dc32dab3 persist max block size setting in LayoutPanel component (#134)
https://github.com/readest/readest/issues/131
2025-01-09 12:23:11 +01:00
Huajin 33d4dcd740 Better animation for sidebar transitions (#133)
* Add transition for BooknoteItem

* Add Tab switch animations and tooltip 'Toc' translations.

* Use 'Table of Contents' instead of 'Toc' as key.
2025-01-09 12:16:07 +01:00
Huang Xin 36d6074468 Filter voices list and shutdown TTS when closing book (#130) 2025-01-09 03:33:52 +01:00
Huang Xin cc1396876e Add select voice button in the TTS panel (#129) 2025-01-09 01:34:30 +01:00
Huang Xin f26de3ab0d Add speech speed slider in the TTS panel (#128) 2025-01-08 21:31:12 +01:00
Huang Xin 814a0032d0 Add 'Speak' button in the footbar (#127) 2025-01-08 19:44:04 +01:00
Huang Xin c6defb97ee Fix TTS might stop at the end of the section (#126) 2025-01-08 17:50:36 +01:00
Huang Xin ddee347eb3 Fix text color in misc panel and adjust order of annotator tools (#125) 2025-01-08 16:50:57 +01:00
Huang Xin 83b10a1df8 Fix color of return button in auth page in dark mode (#124) 2025-01-08 15:44:14 +01:00
Huang Xin 3e43905321 Fix TTS paused after forward/backward (#123) 2025-01-08 15:18:58 +01:00
Huang Xin 89b2eba2af Refactor Toast to global component (#122)
* Refactor Toast to global component, use eventDispatcher to dispatch toast events

* Error handling for TTS
2025-01-08 14:12:38 +01:00
Huang Xin 15d8d61841 Support more than 2 columns in page layout, closes #119 (#121) 2025-01-07 22:32:07 +01:00
Huang Xin 07b04b82e6 Initial implementation of TTS with Web Speech API (#120)
* Initial implementation of TTS with Web Speech API

* Add translations for annotator tools
2025-01-07 21:57:55 +01:00
Huang Xin a59a3b633b Fix progress overridden by newly opened book (#117) 2025-01-06 19:19:02 +01:00
Huang Xin cccd4c2b16 Various fixes (#116)
* Fix missing translations in translator popup

* Disable annotator for PDFs for now, closes #115

* Fix triangle in shadow of popup making it darker
2025-01-06 01:33:35 +01:00
Huang Xin 990f61fb72 Fix layout issues on Windows (#113) 2025-01-05 20:11:23 +01:00
Huang Xin d51f5a632a Regenerate tauri signing key to solve InvalidSignature error when updating 2025-01-05 18:07:49 +01:00
Huang Xin 3e9b89de0e Swap font when downloading online fonts, closes #104 (#111) 2025-01-05 17:26:36 +01:00
Huang Xin f900608d6a Update report-bug template to collect OS and version info 2025-01-05 16:23:48 +01:00
Huang Xin ca56c5a73d Fix no rounded window in reader page on Linux, closes #107 (#110) 2025-01-05 15:50:12 +01:00
Huang Xin 57b72d6d57 Release exe portable files directly to workaround the stupid virus warning on Windows 2025-01-05 14:19:18 +01:00
Huang Xin 76c5f58547 Restore window position and size, closes #7 (#109) 2025-01-05 14:08:56 +01:00
Huang Xin e4a6ae01e0 Release portable binaries for Windows (#102) 2025-01-04 19:46:57 +01:00
Huang Xin 2763c18d6f Fix dropdown close overlay not working on Windows/Linux (#101) 2025-01-04 17:13:53 +01:00
Huang Xin 71d0735917 Various fixes (#99)
* Add shortcut to page turning with the space key on keyboard

* Properly handle auto refresh token with supabase

* Fix settings not stored in direct quit of app, closes #94
2025-01-04 14:18:57 +01:00
Huang Xin 3484c893df Fix deeplink on Windows (#95) 2025-01-04 11:20:31 +01:00
Huang Xin e885d22631 Add deeplink to parse oauth callback url in tauri apps (#92) 2025-01-03 23:24:30 +01:00
Huang Xin 3ad26d9d8f Add vertical/horizontal switch in Layout configs for CJK books (#91) 2025-01-03 21:24:15 +01:00
Huang Xin b699fc98c2 Fix title layout of book details modal and apply of custom css deletion (#90)
* Fix custom css cannot apply after deletion

* Fix title layout of book details modal, closes #89
2025-01-03 19:12:25 +01:00
Huang Xin 2c9fe8e4f4 Better Custom CSS editor (#88) 2025-01-03 14:52:31 +01:00
Huang Xin f425ff8ffa Release version 0.8.9 (#85)
* Update README

* Release version 0.8.9
2025-01-02 23:44:35 +01:00
Huang Xin d6e0fd3b4d Fix supabase settings of oauth for tauri app (#83) 2025-01-02 23:03:50 +01:00
dependabot[bot] 2e2fad849b Bump the github-actions group with 3 updates (#82) 2025-01-02 15:31:39 +01:00
Christian Clauss 572ddd923c Fix typos discovered by codespell (#80) 2025-01-02 15:29:27 +01:00
Christian Clauss adab6f8d9b Keep GitHub Actions up to date with GitHub's Dependabot (#81) 2025-01-02 15:27:16 +01:00
Ray-D-Song f3f8df033d Fix background color of search results (#79) 2025-01-02 09:34:19 +01:00
Huang Xin 50f75c1f18 Show error toast for failed imported books and styles for dropdown menus (#76)
* Styles for dropdown menus

* Show error toast for failed imported books
2024-12-31 18:35:51 +01:00
Huang Xin 1ad13d0b7e Fix translations and line-height settings not working for some books (#73) 2024-12-31 14:42:08 +01:00
Huang Xin cd67f6ec3f Various enhancements and fixes (#70)
* Fix excerpts cannot be deleted in Chrome browsers

* Show book details in reader sidebar

* Set minimum search term length to 1 for CJK languages

* Use more system fonts as custom fonts, closes #31
2024-12-31 01:08:27 +01:00
Huang Xin 1be567ed4a Setup CI for pull request: only build the web-app 2024-12-31 01:01:35 +01:00
Huang Xin 0a46fe8648 Add missing hasWindowBar in BaseAppService 2024-12-30 09:49:20 +01:00
QP Hou abb1b7ae9c add support for ios dev build (#68) 2024-12-30 09:37:01 +01:00
Huang Xin 75e872af51 Bookshelf enhancements: 1. show details in context menu; 2. support some not found book covers (#64)
* Add show book details and open book in context menu

* Support extracting some book covers not included in the meta or guide sections
2024-12-30 00:09:23 +01:00
chrox d1b6a0db1e Release version 0.8.8 2024-12-29 13:09:40 +01:00
chrox 870607f9c3 Add missing translations 2024-12-29 10:42:35 +01:00
chrox fc163926ad Use mainBinaryName for binary execuable name 2024-12-29 10:02:30 +01:00
Huang Xin 4a33409559 Some CSS tweaks to better support dark mode and embedded fonts (#63) 2024-12-28 23:24:59 +01:00
chrox b8af483229 Rename binary execuable to readest for better cli interface, closes #61
Note that this does not affect the product name which is still Readest
and the package name is not changed.
2024-12-28 17:13:37 +01:00
chrox ba9490ad00 Use ubuntu-22.04 to build Linux binaries to support Debian Bookworm
Add Windows arm64 build
Fix release notes format
2024-12-28 10:16:55 +01:00
122 changed files with 4107 additions and 614 deletions
+9 -3
View File
@@ -1,7 +1,7 @@
---
name: Report a bug
about: Report a bug or a functional regression
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling'
labels: ['type: bug']
assignees: ''
---
@@ -30,9 +30,15 @@ The blank square should be transparent (invisible)
## Technical inputs
Operating System:
Readest Version:
**Example:**
```
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
- Probably fixable with CSS
Operating System: macOS 14.3.1
Readest Version: 0.9.0
We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
Probably fixable with CSS
```
+13
View File
@@ -0,0 +1,13 @@
# Keep GitHub Actions up to date with GitHub's Dependabot...
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
version: 2
updates:
- package-ecosystem: github-actions
directory: /
groups:
github-actions:
patterns:
- "*" # Group all Actions updates into a single larger pull request
schedule:
interval: weekly
+35
View File
@@ -0,0 +1,35 @@
name: Build Web Application on Pull Request
on:
pull_request:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
build_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.1
- name: setup node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install && pnpm setup-pdfjs
- name: build the web App
working-directory: apps/readest-app
run: |
pnpm build-web
+58 -20
View File
@@ -11,28 +11,31 @@ jobs:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.get-release.outputs.result }}
release_note: ${{ steps.get-release-notes.outputs.result }}
release_id: ${{ steps.get-release.outputs.release_id }}
release_tag: ${{ steps.get-release.outputs.release_tag }}
release_note: ${{ steps.get-release-notes.outputs.release_note }}
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
- name: get version
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
- name: get release
id: get-release
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const { data } = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
})
return data.id
core.setOutput('release_id', data.id);
core.setOutput('release_tag', data.tag_name);
- name: get release notes
id: get-release-notes
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
@@ -40,9 +43,10 @@ jobs:
const releaseNotesFileContent = fs.readFileSync('./apps/readest-app/release-notes.json', 'utf8');
const releaseNotes = JSON.parse(releaseNotesFileContent).releases[version] || {};
const notes = releaseNotes.notes || [];
const releaseNote = notes.map(note => `- ${note}`).join('\n');
console.log('Release note:\n', releaseNote);
return releaseNote;
const releaseNote = notes.map((note, index) => `${index + 1}. ${note}`).join(' ');
console.log('Formatted release note:', releaseNote);
core.setOutput('release_version', version);
core.setOutput('release_note', releaseNote);
build-tauri:
needs: get-release
@@ -52,20 +56,26 @@ jobs:
fail-fast: false
matrix:
config:
- os: ubuntu-latest
- os: ubuntu-22.04
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: macos-latest
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
args: '--target universal-apple-darwin'
- os: windows-latest
arch: x86_64
rust_target: x86_64-pc-windows-msvc
args: '--target x86_64-pc-windows-msvc'
- os: windows-latest
arch: aarch64
rust_target: aarch64-pc-windows-msvc
args: '--target aarch64-pc-windows-msvc --bundles nsis'
runs-on: ${{ matrix.config.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: initialize git submodules
run: git submodule update --init --recursive
@@ -76,14 +86,11 @@ jobs:
version: 9.14.4
- name: setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: install rimraf
run: npm install -g rimraf
- name: install dependencies
run: pnpm install
@@ -105,13 +112,15 @@ jobs:
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
- name: copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app/.env.local
- name: install dependencies (ubuntu only)
if: matrix.config.os == 'ubuntu-latest'
if: matrix.config.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
@@ -131,7 +140,35 @@ jobs:
projectPath: apps/readest-app
releaseId: ${{ needs.get-release.outputs.release_id }}
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
args: ${{ matrix.config.args || '' }}
- name: upload portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
echo "Uploading Portable Binaries"
arch=${{ matrix.config.arch }}
version=${{ needs.get-release.outputs.release_version }}
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
else
echo "Unknown architecture: $arch"
exit 1
fi
exe_file="apps/readest-app/src-tauri/target/${{ matrix.config.rust_target }}/release/readest.exe"
# Browsers on Windows won't download zip files that contain exe files
# so upload the exe files instead. This is totally stupid.
# powershell.exe -Command "Compress-Archive -Path $exe_file -DestinationPath $bin_file -Force"
cp $exe_file $bin_file
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
update-release:
permissions:
@@ -142,17 +179,18 @@ jobs:
steps:
- name: update release
id: update-release
uses: actions/github-script@v6
uses: actions/github-script@v7
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const body = `## Release Highlight\n${process.env.release_note}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: process.env.release_note,
body: body,
draft: false,
prerelease: false
})
-25
View File
@@ -1,25 +0,0 @@
name: Create vercel preview URL on pull request
on:
pull_request:
branches: [main, master]
permissions:
contents: write
deployments: write
pull-requests: write
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: 'true'
- uses: amondnet/vercel-action@v25
id: vercel-deploy
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
vercel-org-id: ${{ secrets.ORG_ID}}
vercel-project-id: ${{ secrets.PROJECT_ID}}
- name: preview-url
run: |
echo ${{ steps.vercel-deploy.outputs.preview-url }}
+1 -1
View File
@@ -33,7 +33,7 @@ To get started with Readest, follow these steps to clone and build the project.
#### 1. Clone the Repository
```bash
git clone https://github.com/chrox/readest.git
git clone https://github.com/readest/readest.git
cd readest
git submodule update --init --recursive
```
+47 -26
View File
@@ -1,6 +1,6 @@
<div align="center">
<a href="https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank">
<img src="https://github.com/chrox/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
<img src="https://github.com/readest/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
</a>
<h1>Readest</h1>
<br>
@@ -52,25 +52,25 @@
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
## Planned Features
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
| **Feature** | **Description** | **Priority** |
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
| **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. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -80,11 +80,13 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![DeepL](./data/screenshots/deepl.png)
![TTS](./data/screenshots/tts_control.png)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
![Dark Mode](./data/screenshots/dark_mode.png)
![Themeing Dark Mode](./data/screenshots/theming_dark_mode.png)
---
@@ -118,7 +120,7 @@ To get started with Readest, follow these steps to clone and build the project.
### 1. Clone the Repository
```bash
git clone https://github.com/chrox/readest.git
git clone https://github.com/readest/readest.git
cd readest
git submodule update --init --recursive
```
@@ -150,19 +152,29 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
pnpm tauri dev
```
For iOS:
```bash
pnpm tauri ios dev
```
### 5. Build for Production
```bash
pnpm tauri build
```
### 6. More information
Please check the [wiki][link-gh-wiki] of this project for more information on development.
## Contributors
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
<a href="https://github.com/chrox/readest/graphs/contributors">
<a href="https://github.com/readest/readest/graphs/contributors">
<p align="left">
<img width="100" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
<img width="300" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
@@ -170,12 +182,20 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
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.
The following JavaScript libraries are bundled in this software:
The following libraries and software are used in this software:
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
- [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.
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
- [react](https://github.com/facebook/react), which is MIT licensed.
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
[Bitter](https://fonts.google.com/?query=Bitter), [Fira Code](https://fonts.google.com/?query=Fira+Code), [Literata](https://fonts.google.com/?query=Literata), [Merriweather](https://fonts.google.com/?query=Merriweather), [Noto Sans](https://fonts.google.com/?query=Noto+Sans), [Roboto](https://fonts.google.com/?query=Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
---
@@ -183,18 +203,19 @@ The following JavaScript libraries are bundled in this software:
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
[badge-license]: https://img.shields.io/github/license/chrox/readest?color=teal
[badge-release]: https://img.shields.io/github/release/chrox/readest?color=green
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
[badge-last-commit]: https://img.shields.io/github/last-commit/chrox/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/chrox/readest
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
[link-macos-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
[link-gh-releases]: https://github.com/chrox/readest/releases
[link-gh-commits]: https://github.com/chrox/readest/commits/main
[link-gh-pulse]: https://github.com/chrox/readest/pulse
[link-gh-releases]: https://github.com/readest/readest/releases
[link-gh-commits]: https://github.com/readest/readest/commits/main
[link-gh-pulse]: https://github.com/readest/readest/pulse
[link-gh-wiki]: https://github.com/readest/readest/wiki
[link-discord]: https://discord.gg/gntyVNk3BJ
[link-parallel-read]: https://readest.com/#parallel-read
[link-koreader]: https://github.com/koreader/koreader
+1 -1
View File
@@ -3,7 +3,7 @@ module.exports = {
output: '.',
options: {
debug: false,
sort: true,
sort: false,
func: {
list: ['_'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.8.7",
"version": "0.9.5",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -35,6 +35,7 @@
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.1.1",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-http": "^2.2.0",
@@ -54,11 +55,11 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"next": "15.1.0",
"posthog-js": "^1.194.1",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.3.0",
"react-icons": "^5.4.0",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
},
@@ -72,7 +73,7 @@
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.14",
"daisyui": "^4.12.23",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
@@ -83,7 +84,7 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.15",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
@@ -1,7 +1,11 @@
{
"(detected)": "(erkannt)",
"About Readest": "Über Readest",
"Add your notes here...": "Fügen Sie hier Ihre Notizen hinzu...",
"Animation": "Animation",
"Annotate": "Annotieren",
"Apply": "Anwenden",
"Are you sure to delete the selected books?": "Möchten Sie die ausgewählten Bücher wirklich löschen?",
"Auto Mode": "Automatischer Modus",
"Behavior": "Verhalten",
"Book": "Buch",
@@ -13,17 +17,23 @@
"Color": "Farbe",
"Confirm": "Bestätigen",
"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",
"Default Font Size": "Standard-Schriftgröße",
"Delete": "Löschen",
"Delete Highlight": "Hervorhebung löschen",
"Dictionary": "Wörterbuch",
"Disable Click-to-Flip": "Klick zum Blättern deaktivieren",
"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",
"Font": "Schriftart",
"Font & Layout": "Schrift & Layout",
"Font Face": "Schriftschnitt",
@@ -40,6 +50,8 @@
"Grass": "Grasgrün",
"Gray": "Grau",
"Gruvbox": "Gruvbox",
"Highlight": "Hervorheben",
"Horizontal Direction": "Horizontale Richtung",
"Hyphenation": "Silbentrennung",
"Identifier:": "Kennung:",
"Import Books": "Bücher importieren",
@@ -67,6 +79,8 @@
"Notebook": "Notizbuch",
"Notes": "Notizen",
"Open": "Öffnen",
"Open Book": "Buch öffnen",
"Original Text": "Originaltext",
"Override Publisher Font": "Verleger-Schriftart überschreiben",
"Page": "Seite",
"Paging Animation": "Blätter-Animation",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Serifenlose Schriftart",
"Save": "Speichern",
"Scrolled Mode": "Scroll-Modus",
"Search": "Suchen",
"Search books...": "Bücher suchen...",
"Search...": "Suchen...",
"Select Book": "Buch auswählen",
"Select books": "Bücher auswählen",
"Select multiple books": "Mehrere Bücher auswählen",
"Sepia": "Sepia",
"Serif Font": "Serifenschrift",
"Show Book Details": "Buchdetails anzeigen",
"Sidebar": "Seitenleiste",
"Sign In": "Anmelden",
"Sign Out": "Abmelden",
"Sky": "Himmelblau",
"Slow": "Langsam",
"Solarized": "Solarisiert",
"Speak": "Sprechen",
"Subjects:": "Themen:",
"System Fonts": "System-Schriftarten",
"Table of Contents": "Inhaltsverzeichnis",
"TTS not supported in this device": "TTS wird auf diesem Gerät nicht unterstützt",
"Theme Color": "Themenfarbe",
"Theme Mode": "Themenmodus",
"Translate": "Übersetzen",
"Translated Text": "Übersetzter Text",
"Unknown": "Unbekannt",
"Untitled": "Ohne Titel",
"Updated:": "Aktualisiert:",
"User avatar": "Benutzer-Avatar",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Vertikale Richtung",
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
"Your Library": "Ihre Bibliothek"
"Wikipedia": "Wikipedia",
"Writing Mode": "Schreibmodus",
"Your Library": "Ihre Bibliothek",
"TTS not supported for PDF": "TTS wird für PDF nicht unterstützt"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(detectado)",
"About Readest": "Acerca de Readest",
"Add your notes here...": "Añade tus notas aquí...",
"Animation": "Animación",
"Annotate": "Anotar",
"Apply": "Aplicar",
"Are you sure to delete the selected books?": "¿Estás seguro de eliminar los libros seleccionados?",
"Auto Mode": "Modo automático",
"Behavior": "Comportamiento",
"Book": "Libro",
@@ -13,17 +17,23 @@
"Color": "Color",
"Confirm": "Confirmar",
"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",
"Default Font Size": "Tamaño de fuente predeterminado",
"Delete": "Eliminar",
"Delete Highlight": "Eliminar resaltado",
"Dictionary": "Diccionario",
"Disable Click-to-Flip": "Desactivar clic para voltear",
"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",
"Font": "Fuente",
"Font & Layout": "Fuente y diseño",
"Font Face": "Tipo de fuente",
@@ -40,6 +50,8 @@
"Grass": "Hierba",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Resaltar",
"Horizontal Direction": "Dirección horizontal",
"Hyphenation": "Guionización",
"Identifier:": "Identificador:",
"Import Books": "Importar libros",
@@ -67,6 +79,8 @@
"Notebook": "Cuaderno",
"Notes": "Notas",
"Open": "Abrir",
"Open Book": "Abrir libro",
"Original Text": "Texto original",
"Override Publisher Font": "Sobrescribir fuente del editor",
"Page": "Página",
"Paging Animation": "Animación de paginación",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Fuente sans-serif",
"Save": "Guardar",
"Scrolled Mode": "Modo desplazamiento",
"Search": "Buscar",
"Search books...": "Buscar libros...",
"Search...": "Buscar...",
"Select Book": "Seleccionar libro",
"Select books": "Seleccionar libros",
"Select multiple books": "Seleccionar varios libros",
"Sepia": "Sepia",
"Serif Font": "Fuente serif",
"Show Book Details": "Mostrar detalles del libro",
"Sidebar": "Barra lateral",
"Sign In": "Iniciar sesión",
"Sign Out": "Cerrar sesión",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Leer",
"Subjects:": "Temas:",
"System Fonts": "Fuentes del sistema",
"Table of Contents": "Tabla de contenidos",
"TTS not supported in this device": "TTS no es compatible con este dispositivo",
"Theme Color": "Color del tema",
"Theme Mode": "Modo del tema",
"Translate": "Traducir",
"Translated Text": "Texto traducido",
"Unknown": "Desconocido",
"Untitled": "Sin título",
"Updated:": "Actualizado:",
"User avatar": "Avatar del usuario",
"Version {{version}}": "Versión {{version}}",
"Vertical Direction": "Dirección vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
"Your Library": "Tu biblioteca"
"Wikipedia": "Wikipedia",
"Writing Mode": "Modo de escritura",
"Your Library": "Tu biblioteca",
"TTS not supported for PDF": "TTS no es compatible con PDF"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(détecté)",
"About Readest": "À propos de Readest",
"Add your notes here...": "Ajoutez vos notes ici...",
"Animation": "Animation",
"Annotate": "Annoter",
"Apply": "Appliquer",
"Are you sure to delete the selected books?": "Êtes-vous sûr de vouloir supprimer les livres sélectionnés ?",
"Auto Mode": "Mode automatique",
"Behavior": "Comportement",
"Book": "Livre",
@@ -13,17 +17,23 @@
"Color": "Couleur",
"Confirm": "Confirmer",
"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",
"Default Font Size": "Taille de police par défaut",
"Delete": "Supprimer",
"Delete Highlight": "Supprimer le surlignage",
"Dictionary": "Dictionnaire",
"Disable Click-to-Flip": "Désactiver le clic pour tourner",
"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",
"Font": "Police",
"Font & Layout": "Police et mise en page",
"Font Face": "Style de police",
@@ -40,6 +50,8 @@
"Grass": "Herbe",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Surligner",
"Horizontal Direction": "Direction horizontale",
"Hyphenation": "Césure",
"Identifier:": "Identifiant :",
"Import Books": "Importer des livres",
@@ -67,6 +79,8 @@
"Notebook": "Carnet de notes",
"Notes": "Notes",
"Open": "Ouvrir",
"Open Book": "Ouvrir le livre",
"Original Text": "Texte original",
"Override Publisher Font": "Remplacer la police de l'éditeur",
"Page": "Page",
"Paging Animation": "Animation de page",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Police sans empattement",
"Save": "Enregistrer",
"Scrolled Mode": "Mode défilement",
"Search": "Rechercher",
"Search books...": "Rechercher des livres...",
"Search...": "Rechercher...",
"Select Book": "Sélectionner un livre",
"Select books": "Sélectionner des livres",
"Select multiple books": "Sélectionner plusieurs livres",
"Sepia": "Sépia",
"Serif Font": "Police avec empattement",
"Show Book Details": "Afficher les détails du livre",
"Sidebar": "Barre latérale",
"Sign In": "Se connecter",
"Sign Out": "Se déconnecter",
"Sky": "Ciel",
"Slow": "Lent",
"Solarized": "Solarisé",
"Speak": "Lire",
"Subjects:": "Sujets :",
"System Fonts": "Polices système",
"Table of Contents": "Table des matières",
"TTS not supported in this device": "La synthèse vocale n'est pas prise en charge sur cet appareil",
"Theme Color": "Couleur du thème",
"Theme Mode": "Mode du thème",
"Translate": "Traduire",
"Translated Text": "Texte traduit",
"Unknown": "Inconnu",
"Untitled": "Sans titre",
"Updated:": "Mis à jour :",
"User avatar": "Avatar de l'utilisateur",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Direction verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
"Your Library": "Votre bibliothèque"
"Wikipedia": "Wikipédia",
"Writing Mode": "Mode écriture",
"Your Library": "Votre bibliothèque",
"TTS not supported for PDF": "La synthèse vocale n'est pas prise en charge pour les fichiers PDF"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(terdeteksi)",
"About Readest": "Tentang Readest",
"Add your notes here...": "Tambahkan catatan Anda di sini...",
"Animation": "Animasi",
"Annotate": "Anotasi",
"Apply": "Terapkan",
"Are you sure to delete the selected books?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
"Auto Mode": "Mode Otomatis",
"Behavior": "Perilaku",
"Book": "Buku",
@@ -13,17 +17,23 @@
"Color": "Warna",
"Confirm": "Konfirmasi",
"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",
"Default Font Size": "Ukuran Font Default",
"Delete": "Hapus",
"Delete Highlight": "Hapus Sorotan",
"Dictionary": "Kamus",
"Disable Click-to-Flip": "Nonaktifkan Klik untuk Membalik",
"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",
"Font": "Font",
"Font & Layout": "Font & Tata Letak",
"Font Face": "Jenis Font",
@@ -40,6 +50,8 @@
"Grass": "Rumput",
"Gray": "Abu-abu",
"Gruvbox": "Gruvbox",
"Highlight": "Sorot",
"Horizontal Direction": "Arah Horizontal",
"Hyphenation": "Pemenggalan Kata",
"Identifier:": "Pengenal:",
"Import Books": "Impor Buku",
@@ -67,6 +79,8 @@
"Notebook": "Buku Catatan",
"Notes": "Catatan",
"Open": "Buka",
"Open Book": "Buka Buku",
"Original Text": "Teks Asli",
"Override Publisher Font": "Ganti Font Penerbit",
"Page": "Halaman",
"Paging Animation": "Animasi Halaman",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Font Sans-Serif",
"Save": "Simpan",
"Scrolled Mode": "Mode Gulir",
"Search": "Cari",
"Search books...": "Cari buku...",
"Search...": "Cari...",
"Select Book": "Pilih Buku",
"Select books": "Pilih buku",
"Select multiple books": "Pilih beberapa buku",
"Sepia": "Sepia",
"Serif Font": "Font Serif",
"Show Book Details": "Tampilkan Detail Buku",
"Sidebar": "Bilah Samping",
"Sign In": "Masuk",
"Sign Out": "Keluar",
"Sky": "Langit",
"Slow": "Lambat",
"Solarized": "Solarized",
"Speak": "Bicara",
"Subjects:": "Subjek:",
"System Fonts": "Font Sistem",
"Table of Contents": "Daftar Isi",
"TTS not supported in this device": "TTS tidak didukung di perangkat ini",
"Theme Color": "Warna Tema",
"Theme Mode": "Mode Tema",
"Translate": "Terjemahkan",
"Translated Text": "Teks Terjemahan",
"Unknown": "Tidak Diketahui",
"Untitled": "Tanpa Judul",
"Updated:": "Diperbarui:",
"User avatar": "Avatar Pengguna",
"Version {{version}}": "Versi {{version}}",
"Vertical Direction": "Arah Vertikal",
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
"Your Library": "Perpustakaan Anda"
"Wikipedia": "Wikipedia",
"Writing Mode": "Mode Menulis",
"Your Library": "Perpustakaan Anda",
"TTS not supported for PDF": "TTS tidak didukung untuk PDF"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(rilevato)",
"About Readest": "Informazioni su Readest",
"Add your notes here...": "Aggiungi qui le tue note...",
"Animation": "Animazione",
"Annotate": "Annota",
"Apply": "Applica",
"Are you sure to delete the selected books?": "Sei sicuro di voler eliminare i libri selezionati?",
"Auto Mode": "Modalità automatica",
"Behavior": "Comportamento",
"Book": "Libro",
@@ -13,17 +17,23 @@
"Color": "Colore",
"Confirm": "Conferma",
"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",
"Default Font Size": "Dimensione font predefinita",
"Delete": "Elimina",
"Delete Highlight": "Elimina evidenziazione",
"Dictionary": "Dizionario",
"Disable Click-to-Flip": "Disattiva click per voltare pagina",
"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",
"Font": "Font",
"Font & Layout": "Font e Layout",
"Font Face": "Tipo di carattere",
@@ -40,6 +50,8 @@
"Grass": "Erba",
"Gray": "Grigio",
"Gruvbox": "Gruvbox",
"Highlight": "Evidenzia",
"Horizontal Direction": "Direzione orizzontale",
"Hyphenation": "Sillabazione",
"Identifier:": "Identificatore:",
"Import Books": "Importa libri",
@@ -67,6 +79,8 @@
"Notebook": "Quaderno",
"Notes": "Note",
"Open": "Apri",
"Open Book": "Apri libro",
"Original Text": "Testo originale",
"Override Publisher Font": "Sostituisci font editore",
"Page": "Pagina",
"Paging Animation": "Animazione cambio pagina",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Font sans-serif",
"Save": "Salva",
"Scrolled Mode": "Modalità scorrimento",
"Search": "Cerca",
"Search books...": "Cerca libri...",
"Search...": "Cerca...",
"Select Book": "Seleziona libro",
"Select books": "Seleziona libri",
"Select multiple books": "Seleziona più libri",
"Sepia": "Seppia",
"Serif Font": "Font serif",
"Show Book Details": "Mostra dettagli libro",
"Sidebar": "Barra laterale",
"Sign In": "Accedi",
"Sign Out": "Esci",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizzato",
"Speak": "Leggi",
"Subjects:": "Argomenti:",
"System Fonts": "Font di sistema",
"Table of Contents": "Indice",
"TTS not supported in this device": "TTS non supportato su questo dispositivo",
"Theme Color": "Colore tema",
"Theme Mode": "Modalità tema",
"Translate": "Traduci",
"Translated Text": "Testo tradotto",
"Unknown": "Sconosciuto",
"Untitled": "Senza titolo",
"Updated:": "Aggiornato:",
"User avatar": "Avatar utente",
"Version {{version}}": "Versione {{version}}",
"Vertical Direction": "Direzione verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
"Your Library": "La tua biblioteca"
"Wikipedia": "Wikipedia",
"Writing Mode": "Modalità scrittura",
"Your Library": "La tua biblioteca",
"TTS not supported for PDF": "TTS non supportato per PDF"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(検出)",
"About Readest": "Readestについて",
"Add your notes here...": "ここにメモを追加...",
"Animation": "アニメーション",
"Annotate": "注釈",
"Apply": "適用",
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
"Auto Mode": "自動モード",
"Behavior": "動作",
"Book": "書籍",
@@ -13,17 +17,23 @@
"Color": "色",
"Confirm": "確認",
"Confirm Deletion": "削除の確認",
"Copied to notebook": "ノートブックにコピー",
"Copy": "コピー",
"Custom CSS": "カスタムCSS",
"Dark Mode": "ダークモード",
"Default": "デフォルト",
"Default Font": "デフォルトフォント",
"Default Font Size": "デフォルトフォントサイズ",
"Delete": "削除",
"Delete Highlight": "ハイライトを削除",
"Dictionary": "辞書",
"Disable Click-to-Flip": "クリックめくりを無効化",
"Download Readest": "Readestをダウンロード",
"Edit": "編集",
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
"Excerpts": "抜粋",
"Failed to import book(s): {{filenames}}": "書籍のインポートに失敗しました:{{filenames}}",
"Fast": "高速",
"Font": "フォント",
"Font & Layout": "フォントとレイアウト",
"Font Face": "書体",
@@ -40,6 +50,8 @@
"Grass": "グリーン",
"Gray": "グレー",
"Gruvbox": "グルーブボックス",
"Highlight": "ハイライト",
"Horizontal Direction": "横書",
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
@@ -67,6 +79,8 @@
"Notebook": "ノート",
"Notes": "メモ",
"Open": "開く",
"Open Book": "書籍を開く",
"Original Text": "原文",
"Override Publisher Font": "出版社フォントを上書き",
"Page": "ページ",
"Paging Animation": "ページめくりアニメーション",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "ゴシック体",
"Save": "保存",
"Scrolled Mode": "スクロールモード",
"Search": "検索",
"Search books...": "書籍を検索...",
"Search...": "検索...",
"Select Book": "書籍を選択",
"Select books": "書籍を選択",
"Select multiple books": "複数の書籍を選択",
"Sepia": "セピア",
"Serif Font": "明朝体",
"Show Book Details": "書籍の詳細を表示",
"Sidebar": "サイドバー",
"Sign In": "サインイン",
"Sign Out": "サインアウト",
"Sky": "スカイ",
"Slow": "低速",
"Solarized": "ソーラライズド",
"Speak": "読み上げ",
"Subjects:": "主題:",
"System Fonts": "システムフォント",
"Table of Contents": "目次",
"TTS not supported in this device": "このデバイスではTTSがサポートされていません",
"Theme Color": "テーマカラー",
"Theme Mode": "テーマモード",
"Translate": "翻訳",
"Translated Text": "翻訳されたテキスト",
"Unknown": "不明",
"Untitled": "無題",
"Updated:": "更新日:",
"User avatar": "ユーザーアバター",
"Version {{version}}": "バージョン {{version}}",
"Vertical Direction": "縦書",
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
"Your Library": "ライブラリー"
"Wikipedia": "ウィキペディア",
"Writing Mode": "書き込みモード",
"Your Library": "ライブラリー",
"TTS not supported for PDF": "PDFではTTSがサポートされていません"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(감지됨)",
"About Readest": "Readest 정보",
"Add your notes here...": "여기에 메모를 추가하세요...",
"Animation": "애니메이션",
"Annotate": "주석",
"Apply": "적용",
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
"Auto Mode": "자동 모드",
"Behavior": "동작",
"Book": "책",
@@ -13,17 +17,23 @@
"Color": "색상",
"Confirm": "확인",
"Confirm Deletion": "삭제 확인",
"Copied to notebook": "노트북에 복사됨",
"Copy": "복사",
"Custom CSS": "사용자 정의 CSS",
"Dark Mode": "다크 모드",
"Default": "기본값",
"Default Font": "기본 글꼴",
"Default Font Size": "기본 글꼴 크기",
"Delete": "삭제",
"Delete Highlight": "하이라이트 삭제",
"Dictionary": "사전",
"Disable Click-to-Flip": "클릭 넘기기 비활성화",
"Download Readest": "Readest 다운로드",
"Edit": "편집",
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
"Excerpts": "발췌",
"Failed to import book(s): {{filenames}}": "책 가져오기 실패: {{filenames}}",
"Fast": "빠름",
"Font": "글꼴",
"Font & Layout": "글꼴 및 레이아웃",
"Font Face": "글꼴 스타일",
@@ -40,6 +50,8 @@
"Grass": "잔디색",
"Gray": "회색",
"Gruvbox": "그러브박스",
"Highlight": "하이라이트",
"Horizontal Direction": "수평 방향",
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
@@ -67,6 +79,8 @@
"Notebook": "노트북",
"Notes": "메모",
"Open": "열기",
"Open Book": "책 열기",
"Original Text": "원본 텍스트",
"Override Publisher Font": "출판사 글꼴 재정의",
"Page": "페이지",
"Paging Animation": "페이지 넘김 애니메이션",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "산세리프체",
"Save": "저장",
"Scrolled Mode": "스크롤 모드",
"Search": "검색",
"Search books...": "책 검색...",
"Search...": "검색...",
"Select Book": "책 선택",
"Select books": "책 선택",
"Select multiple books": "여러 책 선택",
"Sepia": "세피아",
"Serif Font": "세리프체",
"Show Book Details": "책 세부 정보 표시",
"Sidebar": "사이드바",
"Sign In": "로그인",
"Sign Out": "로그아웃",
"Sky": "하늘색",
"Slow": "느림",
"Solarized": "솔라라이즈드",
"Speak": "말하기",
"Subjects:": "주제:",
"System Fonts": "시스템 글꼴",
"Table of Contents": "목차",
"TTS not supported in this device": "이 장치에서 TTS가 지원되지 않음",
"Theme Color": "테마 색상",
"Theme Mode": "테마 모드",
"Translate": "번역",
"Translated Text": "번역된 텍스트",
"Unknown": "알 수 없음",
"Untitled": "제목 없음",
"Updated:": "업데이트일:",
"User avatar": "사용자 아바타",
"Version {{version}}": "버전 {{version}}",
"Vertical Direction": "수직 방향",
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
"Your Library": "내 서재"
"Wikipedia": "위키피디아",
"Writing Mode": "글쓰기 모드",
"Your Library": "내 서재",
"TTS not supported for PDF": "PDF에서 TTS가 지원되지 않음"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(detectado)",
"About Readest": "Sobre o Readest",
"Add your notes here...": "Adicione suas notas aqui...",
"Animation": "Animação",
"Annotate": "Anotar",
"Apply": "Aplicar",
"Are you sure to delete the selected books?": "Tem certeza de que deseja excluir os livros selecionados?",
"Auto Mode": "Modo Automático",
"Behavior": "Comportamento",
"Book": "Livro",
@@ -13,17 +17,23 @@
"Color": "Cor",
"Confirm": "Confirmar",
"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",
"Default Font Size": "Tamanho da Fonte Padrão",
"Delete": "Excluir",
"Delete Highlight": "Excluir Destaque",
"Dictionary": "Dicionário",
"Disable Click-to-Flip": "Desativar Clique para Virar",
"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",
"Font": "Fonte",
"Font & Layout": "Fonte e Layout",
"Font Face": "Estilo da Fonte",
@@ -40,6 +50,8 @@
"Grass": "Grama",
"Gray": "Cinza",
"Gruvbox": "Gruvbox",
"Highlight": "Destaque",
"Horizontal Direction": "Direção Horizontal",
"Hyphenation": "Hifenização",
"Identifier:": "Identificador:",
"Import Books": "Importar Livros",
@@ -67,6 +79,8 @@
"Notebook": "Caderno",
"Notes": "Notas",
"Open": "Abrir",
"Open Book": "Abrir Livro",
"Original Text": "Texto Original",
"Override Publisher Font": "Substituir Fonte do Editor",
"Page": "Página",
"Paging Animation": "Animação de Página",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Fonte Sans-Serif",
"Save": "Salvar",
"Scrolled Mode": "Modo de Rolagem",
"Search": "Buscar",
"Search books...": "Procurar livros...",
"Search...": "Pesquisar...",
"Select Book": "Selecionar Livro",
"Select books": "Selecionar livros",
"Select multiple books": "Selecionar múltiplos livros",
"Sepia": "Sépia",
"Serif Font": "Fonte Serif",
"Show Book Details": "Mostrar Detalhes do Livro",
"Sidebar": "Barra Lateral",
"Sign In": "Entrar",
"Sign Out": "Sair",
"Sky": "Céu",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Falar",
"Subjects:": "Assuntos:",
"System Fonts": "Fontes do Sistema",
"Table of Contents": "Índice",
"TTS not supported in this device": "TTS não suportado neste dispositivo",
"Theme Color": "Cor do Tema",
"Theme Mode": "Modo do Tema",
"Translate": "Traduzir",
"Translated Text": "Texto Traduzido",
"Unknown": "Desconhecido",
"Untitled": "Sem Título",
"Updated:": "Atualizado:",
"User avatar": "Avatar do usuário",
"Version {{version}}": "Versão {{version}}",
"Vertical Direction": "Direção Vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
"Your Library": "Sua Biblioteca"
"Wikipedia": "Wikipédia",
"Writing Mode": "Modo de Escrita",
"Your Library": "Sua Biblioteca",
"TTS not supported for PDF": "TTS não suportado para PDF"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(обнаружено)",
"About Readest": "О Readest",
"Add your notes here...": "Добавьте свои заметки здесь...",
"Animation": "Анимация",
"Annotate": "Аннотация",
"Apply": "Применить",
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
"Auto Mode": "Автоматический режим",
"Behavior": "Поведение",
"Book": "Книга",
@@ -13,17 +17,23 @@
"Color": "Цвет",
"Confirm": "Подтвердить",
"Confirm Deletion": "Подтвердить удаление",
"Copied to notebook": "Скопировано в блокнот",
"Copy": "Копировать",
"Custom CSS": "Пользовательский CSS",
"Dark Mode": "Тёмная тема",
"Default": "По умолчанию",
"Default Font": "Шрифт по умолчанию",
"Default Font Size": "Размер шрифта по умолчанию",
"Delete": "Удалить",
"Delete Highlight": "Удалить выделение",
"Dictionary": "Словарь",
"Disable Click-to-Flip": "Отключить переворот по клику",
"Download Readest": "Скачать Readest",
"Edit": "Редактировать",
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
"Excerpts": "Отрывки",
"Failed to import book(s): {{filenames}}": "Не удалось импортировать книгу(и): {{filenames}}",
"Fast": "Быстро",
"Font": "Шрифт",
"Font & Layout": "Шрифт и макет",
"Font Face": "Начертание шрифта",
@@ -40,6 +50,8 @@
"Grass": "Трава",
"Gray": "Серый",
"Gruvbox": "Gruvbox",
"Highlight": "Выделить",
"Horizontal Direction": "Горизонтальное направление",
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
@@ -67,6 +79,8 @@
"Notebook": "Блокнот",
"Notes": "Заметки",
"Open": "Открыть",
"Open Book": "Открыть книгу",
"Original Text": "Оригинальный текст",
"Override Publisher Font": "Переопределить шрифт издателя",
"Page": "Страница",
"Paging Animation": "Анимация перелистывания",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Шрифт без засечек",
"Save": "Сохранить",
"Scrolled Mode": "Режим прокрутки",
"Search": "Поиск",
"Search books...": "Поиск книг...",
"Search...": "Поиск...",
"Select Book": "Выбрать книгу",
"Select books": "Выбрать книги",
"Select multiple books": "Выбрать несколько книг",
"Sepia": "Сепия",
"Serif Font": "Шрифт с засечками",
"Show Book Details": "Показать детали книги",
"Sidebar": "Боковая панель",
"Sign In": "Войти",
"Sign Out": "Выйти",
"Sky": "Небесный",
"Slow": "Медленно",
"Solarized": "Солнечный",
"Speak": "Произнести",
"Subjects:": "Темы:",
"System Fonts": "Системные шрифты",
"Table of Contents": "Содержание",
"TTS not supported in this device": "TTS не поддерживается на этом устройстве",
"Theme Color": "Цвет темы",
"Theme Mode": "Режим темы",
"Translate": "Перевести",
"Translated Text": "Переведённый текст",
"Unknown": "Неизвестно",
"Untitled": "Без названия",
"Updated:": "Обновлено:",
"User avatar": "Аватар пользователя",
"Version {{version}}": "Версия {{version}}",
"Vertical Direction": "Вертикальное направление",
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
"Your Library": "Ваша библиотека"
"Wikipedia": "Википедия",
"Writing Mode": "Режим записи",
"Your Library": "Ваша библиотека",
"TTS not supported for PDF": "TTS не поддерживается для PDF"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(algılandı)",
"About Readest": "Readest Hakkında",
"Add your notes here...": "Notlarınızı buraya ekleyin...",
"Animation": "Animasyon",
"Annotate": "Not Ekle",
"Apply": "Uygula",
"Are you sure to delete the selected books?": "Seçili kitapları silmek istediğinizden emin misiniz?",
"Auto Mode": "Otomatik Mod",
"Behavior": "Davranış",
"Book": "Kitap",
@@ -13,17 +17,23 @@
"Color": "Renk",
"Confirm": "Onayla",
"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",
"Default Font Size": "Varsayılan Yazı Boyutu",
"Delete": "Sil",
"Delete Highlight": "Vurguyu Sil",
"Dictionary": "Sözlük",
"Disable Click-to-Flip": "Tıkla-Çevir'i Devre Dışı Bırak",
"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ı",
"Font": "Yazı Tipi",
"Font & Layout": "Yazı Tipi ve Düzen",
"Font Face": "Yazı Tipi Yüzü",
@@ -40,6 +50,8 @@
"Grass": "Çimen",
"Gray": "Gri",
"Gruvbox": "Gruvbox",
"Highlight": "Vurgula",
"Horizontal Direction": "Yatay Yön",
"Hyphenation": "Heceleme",
"Identifier:": "Tanımlayıcı:",
"Import Books": "Kitapları İçe Aktar",
@@ -67,6 +79,8 @@
"Notebook": "Not Defteri",
"Notes": "Notlar",
"Open": "Aç",
"Open Book": "Kitabı Aç",
"Original Text": "Orijinal Metin",
"Override Publisher Font": "Yayıncı Yazı Tipini Geçersiz Kıl",
"Page": "Sayfa",
"Paging Animation": "Sayfa Çevirme Animasyonu",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Sans-Serif Yazı Tipi",
"Save": "Kaydet",
"Scrolled Mode": "Kaydırma Modu",
"Search": "Ara",
"Search books...": "Kitap ara...",
"Search...": "Ara...",
"Select Book": "Kitap Seç",
"Select books": "Kitapları seç",
"Select multiple books": "Birden fazla kitap seç",
"Sepia": "Sepya",
"Serif Font": "Serif Yazı Tipi",
"Show Book Details": "Kitap Detaylarını Göster",
"Sidebar": "Kenar Çubuğu",
"Sign In": "Giriş Yap",
"Sign Out": "Çıkış Yap",
"Sky": "Gökyüzü",
"Slow": "Yavaş",
"Solarized": "Solarized",
"Speak": "Konuş",
"Subjects:": "Konular:",
"System Fonts": "Sistem Yazı Tipleri",
"Table of Contents": "İçindekiler",
"TTS not supported in this device": "Bu cihazda TTS desteklenmiyor",
"Theme Color": "Tema Rengi",
"Theme Mode": "Tema Modu",
"Translate": "Çevir",
"Translated Text": "Çevrilen Metin",
"Unknown": "Bilinmiyor",
"Untitled": "Başlıksız",
"Updated:": "Güncellendi:",
"User avatar": "Kullanıcı avatarı",
"Version {{version}}": "Sürüm {{version}}",
"Vertical Direction": "Dikey Yön",
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
"Your Library": "Kütüphaneniz"
"Wikipedia": "Vikipedi",
"Writing Mode": "Yazma Modu",
"Your Library": "Kütüphaneniz",
"TTS not supported for PDF": "PDF için TTS desteklenmiyor"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(đã phát hiện)",
"About Readest": "Về Readest",
"Add your notes here...": "Thêm ghi chú của bạn vào đây...",
"Animation": "Hiệu ứng động",
"Annotate": "Chú thích",
"Apply": "Áp dụng",
"Are you sure to delete the selected books?": "Bạn có chắc chắn muốn xóa các sách đã chọn?",
"Auto Mode": "Chế độ tự động",
"Behavior": "Hành vi",
"Book": "Sách",
@@ -13,17 +17,23 @@
"Color": "Màu sắc",
"Confirm": "Xác nhận",
"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",
"Default Font Size": "Cỡ chữ mặc định",
"Delete": "Xóa",
"Delete Highlight": "Xóa văn bản được đánh dấu",
"Dictionary": "Từ điển",
"Disable Click-to-Flip": "Tắt tính năng nhấp để lật trang",
"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",
"Font": "Phông chữ",
"Font & Layout": "Phông chữ & Bố cục",
"Font Face": "Kiểu chữ",
@@ -40,6 +50,8 @@
"Grass": "Xanh cỏ",
"Gray": "Xám",
"Gruvbox": "Gruvbox",
"Highlight": "Đánh dấu",
"Horizontal Direction": "Hướng ngang",
"Hyphenation": "Gạch nối từ",
"Identifier:": "Định danh:",
"Import Books": "Nhập sách",
@@ -67,6 +79,8 @@
"Notebook": "Sổ ghi chép",
"Notes": "Ghi chú",
"Open": "Mở",
"Open Book": "Mở sách",
"Original Text": "Văn bản gốc",
"Override Publisher Font": "Ghi đè phông chữ của nhà xuất bản",
"Page": "Trang",
"Paging Animation": "Hiệu ứng lật trang",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "Phông chữ không chân",
"Save": "Lưu",
"Scrolled Mode": "Chế độ cuộn",
"Search": "Tìm kiếm",
"Search books...": "Tìm kiếm sách...",
"Search...": "Tìm kiếm...",
"Select Book": "Chọn sách",
"Select books": "Chọn sách",
"Select multiple books": "Chọn nhiều sách",
"Sepia": "Nâu cổ",
"Serif Font": "Phông chữ có chân",
"Show Book Details": "Hiển thị chi tiết sách",
"Sidebar": "Thanh bên",
"Sign In": "Đăng nhập",
"Sign Out": "Đăng xuất",
"Sky": "Xanh trời",
"Slow": "Chậm",
"Solarized": "Solarized",
"Speak": "Đọc",
"Subjects:": "Chủ đề:",
"System Fonts": "Phông chữ hệ thống",
"Table of Contents": "Mục lục",
"TTS not supported in this device": "TTS không được hỗ trợ trên thiết bị này",
"Theme Color": "Màu chủ đề",
"Theme Mode": "Chế độ chủ đề",
"Translate": "Dịch",
"Translated Text": "Văn bản dịch",
"Unknown": "Không xác định",
"Untitled": "Không có tiêu đề",
"Updated:": "Cập nhật:",
"User avatar": "Ảnh đại diện",
"Version {{version}}": "Phiên bản {{version}}",
"Vertical Direction": "Hướng dọc",
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
"Your Library": "Thư viện của bạn"
"Wikipedia": "Wikipedia",
"Writing Mode": "Chế độ viết",
"Your Library": "Thư viện của bạn",
"TTS not supported for PDF": "TTS không được hỗ trợ cho PDF"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(检测到)",
"About Readest": "关于 Readest",
"Add your notes here...": "在这里添加您的笔记...",
"Animation": "动画",
"Annotate": "笔记",
"Apply": "应用",
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
"Auto Mode": "自动主题",
"Behavior": "行为",
"Book": "书籍",
@@ -13,17 +17,23 @@
"Color": "颜色",
"Confirm": "确认",
"Confirm Deletion": "确认删除",
"Copied to notebook": "已复制到笔记本",
"Copy": "复制",
"Custom CSS": "自定义 CSS",
"Dark Mode": "深色主题",
"Default": "默认",
"Default Font": "默认字体",
"Default Font Size": "默认字号",
"Delete": "删除",
"Delete Highlight": "删除划线",
"Dictionary": "词典",
"Disable Click-to-Flip": "禁用点击翻页",
"Download Readest": "下载 Readest",
"Edit": "编辑",
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
"Excerpts": "摘录",
"Failed to import book(s): {{filenames}}": "导入书籍失败:{{filenames}}",
"Fast": "快",
"Font": "字体",
"Font & Layout": "字体和布局",
"Font Face": "字型",
@@ -40,6 +50,8 @@
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "划线",
"Horizontal Direction": "横排",
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
@@ -67,8 +79,10 @@
"Notebook": "笔记本",
"Notes": "笔记",
"Open": "打开",
"Open Book": "打开书籍",
"Original Text": "原文",
"Override Publisher Font": "覆盖内置字体",
"Page": "页",
"Page": "页",
"Paging Animation": "翻页动画",
"Paragraph": "段落",
"Parallel Read": "并排阅读",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "无衬线字体",
"Save": "保存",
"Scrolled Mode": "滚动模式",
"Search": "搜索",
"Search books...": "搜索书籍...",
"Search...": "搜索...",
"Select Book": "选择书籍",
"Select books": "选择书籍",
"Select multiple books": "选择多本书籍",
"Sepia": "旧韵",
"Serif Font": "衬线字体",
"Show Book Details": "显示书籍详情",
"Sidebar": "侧边栏",
"Sign In": "登录",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日晖",
"Speak": "朗读",
"Subjects:": "主题",
"System Fonts": "系统字体",
"Table of Contents": "目录",
"TTS not supported in this device": "此设备不支持 TTS",
"Theme Color": "主题颜色",
"Theme Mode": "主题模式",
"Translate": "翻译",
"Translated Text": "译文",
"Unknown": "未知",
"Untitled": "无标题",
"Updated:": "更新日期",
"User avatar": "用户头像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "竖排",
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
"Your Library": "书库"
"Wikipedia": "维基百科",
"Writing Mode": "排版模式",
"Your Library": "书库",
"TTS not supported for PDF": "PDF 文档暂不支持 TTS"
}
@@ -1,7 +1,11 @@
{
"(detected)": "(檢測到)",
"About Readest": "關於 Readest",
"Add your notes here...": "在這裡添加您的筆記...",
"Animation": "動畫",
"Annotate": "筆記",
"Apply": "應用",
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
"Auto Mode": "自動主題",
"Behavior": "行為",
"Book": "書籍",
@@ -13,17 +17,23 @@
"Color": "顏色",
"Confirm": "確認",
"Confirm Deletion": "確認刪除",
"Copied to notebook": "已複製到筆記本",
"Copy": "複製",
"Custom CSS": "自定義 CSS",
"Dark Mode": "深色主題",
"Default": "預設",
"Default Font": "預設字體",
"Default Font Size": "預設字號",
"Delete": "刪除",
"Delete Highlight": "刪除劃線",
"Dictionary": "詞典",
"Disable Click-to-Flip": "禁用點擊翻頁",
"Download Readest": "下載 Readest",
"Edit": "編輯",
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
"Excerpts": "摘錄",
"Failed to import book(s): {{filenames}}": "導入書籍失敗:{{filenames}}",
"Fast": "快",
"Font": "字體",
"Font & Layout": "字體和版面",
"Font Face": "字型",
@@ -40,6 +50,8 @@
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "劃線",
"Horizontal Direction": "橫排",
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
@@ -67,8 +79,10 @@
"Notebook": "筆記本",
"Notes": "筆記",
"Open": "打開",
"Open Book": "打開書籍",
"Original Text": "原文",
"Override Publisher Font": "覆蓋內置字體",
"Page": "頁",
"Page": "頁",
"Paging Animation": "翻頁動畫",
"Paragraph": "段落",
"Parallel Read": "並排閱讀",
@@ -82,25 +96,39 @@
"Sans-Serif Font": "無襯線字體",
"Save": "保存",
"Scrolled Mode": "滾動模式",
"Search": "搜索",
"Search books...": "搜索書籍...",
"Search...": "搜索...",
"Select Book": "選擇書籍",
"Select books": "選擇書籍",
"Select multiple books": "選擇多本書籍",
"Sepia": "舊韻",
"Serif Font": "襯線字體",
"Show Book Details": "顯示書籍詳情",
"Sidebar": "側邊欄",
"Sign In": "登入",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日暉",
"Speak": "朗讀",
"Subjects:": "主題",
"System Fonts": "系統字體",
"Table of Contents": "目錄",
"TTS not supported in this device": "此設備不支持 TTS",
"Theme Color": "主題顏色",
"Theme Mode": "主題模式",
"Translate": "翻譯",
"Translated Text": "譯文",
"Unknown": "未知",
"Untitled": "無標題",
"Updated:": "更新日期",
"User avatar": "用戶頭像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "豎排",
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
"Your Library": "書庫"
"Wikipedia": "維基百科",
"Writing Mode": "排版模式",
"Your Library": "書庫",
"TTS not supported for PDF": "PDF 文檔暫不支持 TTS"
}
+54 -1
View File
@@ -1,5 +1,58 @@
{
"releases": {
"0.9.5": {
"date": "2025-01-15",
"notes": [
"Various TTS related UX enhancements",
"Add more system fonts as custom fonts",
"Show reading progress in bookshelf"
]
},
"0.9.3": {
"date": "2025-01-11",
"notes": [
"Hotfix occasionally current speak not stopped when changing voice or rate",
"Inherit tts rate when changing tts engine"
]
},
"0.9.2": {
"date": "2025-01-10",
"notes": [
"Support Text-to-Speech with Edge TTS and Web Speech API.",
"Various fixes and enhancements."
]
},
"0.9.1": {
"date": "2025-01-06",
"notes": [
"Restore window position and size when reopening app.",
"Swap font when downloading online fonts.",
"Fix layout issues on Windows and Linux."
]
},
"0.9.0": {
"date": "2025-01-04",
"notes": [
"Better Custom CSS editor and OAuth process.",
"Add vertical/horizontal switch for CJK books.",
"Fix dropdown close overlay not working on Windows/Linux."
]
},
"0.8.9": {
"date": "2025-01-02",
"notes": [
"Context menu on bookshelf for quick actions.",
"Add more system fonts as custom fonts.",
"Fix google and github login for native apps."
]
},
"0.8.8": {
"date": "2024-12-29",
"notes": [
"Use readest binary name for better CLI interface.",
"Some CSS tweaks to better support dark mode and embedded fonts."
]
},
"0.8.7": {
"date": "2024-12-27",
"notes": [
@@ -37,7 +90,7 @@
"notes": [
"Support file associations for one-click open with Readest.",
"Support APP auto updater.",
"Suppport online web version."
"Support online web version."
]
},
"0.7.9": {
+296 -127
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,12 +1,12 @@
[package]
name = "Readest"
version = "0.2.0"
version = "0.2.1"
description = "Your online library"
authors = ["Bilingify LLC"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.71"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -34,6 +34,7 @@ tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -44,4 +45,6 @@ rand = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.0"
@@ -51,17 +51,21 @@
"os:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-center",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-set-focus",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:default",
"updater:default",
"process:default",
"process:allow-exit",
"process:allow-restart",
"cli:default",
"oauth:allow-start",
"oauth:allow-cancel",
"opener:default"
"opener:default",
"deep-link:default"
]
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"windows": ["main"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"updater:default",
"cli:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

+55 -10
View File
@@ -9,7 +9,7 @@ extern crate objc;
#[cfg(target_os = "macos")]
mod menu;
#[cfg(target_os = "macos")]
mod tauri_traffic_light_positioner_plugin;
mod traffic_light_plugin;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -70,11 +70,16 @@ async fn start_server(window: Window) -> Result<u16, String> {
.map_err(|err| err.to_string())
}
#[derive(Clone, serde::Serialize)]
struct Payload {
args: Vec<String>,
cwd: String,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![start_server])
.plugin(tauri_plugin_shell::init())
@@ -84,8 +89,26 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
let _ = app
.get_webview_window("main")
.expect("no main window")
.set_focus();
app.emit("single-instance", Payload { args: argv, cwd })
.unwrap();
}));
let builder = builder.plugin(tauri_plugin_deep_link::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
#[cfg(target_os = "macos")]
let builder = builder.plugin(tauri_traffic_light_positioner_plugin::init());
let builder = builder.plugin(traffic_light_plugin::init());
builder
.setup(|#[allow(unused_variables)] app| {
@@ -94,7 +117,7 @@ pub fn run() {
let mut files = Vec::new();
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
// or arguments (`--`) if your app supports them.
// files may aslo be passed as `file://path/to/file`
// files may also be passed as `file://path/to/file`
for maybe_file in std::env::args().skip(1) {
// skip flags like -f or --flag
if maybe_file.starts_with("-") {
@@ -120,8 +143,25 @@ pub fn run() {
});
}
}
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_cli::init())?;
{
app.handle().plugin(tauri_plugin_cli::init())?;
let app_handle = app.handle().clone();
app.listen("window-ready", move |_| {
app_handle.get_webview_window("main").unwrap()
.eval("window.__READEST_CLI_ACCESS = true; window.__READEST_UPDATER_ACCESS = true;")
.expect("Failed to set cli access config");
});
}
#[cfg(any(windows, target_os = "linux"))]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
#[cfg(desktop)]
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -130,10 +170,13 @@ pub fn run() {
.build(),
)?;
}
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
#[cfg(desktop)]
let win_builder = win_builder
.inner_size(800.0, 600.0)
.resizable(true)
.maximized(true);
.resizable(true);
#[cfg(target_os = "macos")]
let win_builder = win_builder
@@ -141,10 +184,12 @@ pub fn run() {
.title_bar_style(TitleBarStyle::Overlay)
.title("");
#[cfg(not(target_os = "macos"))]
#[cfg(all(not(target_os = "macos"), desktop))]
let win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.shadow(true)
.title("Readest");
win_builder.build().unwrap();
@@ -163,7 +208,7 @@ pub fn run() {
.run(
#[allow(unused_variables)]
|app_handle, event| {
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event {
let files = urls
.into_iter()
+13 -5
View File
@@ -1,8 +1,9 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Readest",
"version": "../package.json",
"mainBinaryName": "readest",
"identifier": "com.bilingify.readest",
"version": "../package.json",
"build": {
"frontendDist": "../out",
"devUrl": "http://localhost:3000",
@@ -13,10 +14,11 @@
"windows": [],
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com https://db.onlinewebfonts.com",
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com",
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net",
"font-src": "'self' data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"frame-src": "'self' blob: asset: http://asset.localhost",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
},
@@ -129,8 +131,14 @@
}
]
},
"deep-link": {
"mobile": [{ "host": "web.readest.com" }],
"desktop": {
"schemes": ["readest"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK",
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
}
}
+58 -26
View File
@@ -17,12 +17,18 @@ import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { 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';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
interface SingleInstancePayload {
args: string[];
cwd: string;
}
interface ProviderLoginProp {
provider: OAuthProvider;
handleSignIn: (provider: OAuthProvider) => void;
@@ -64,7 +70,10 @@ export default function AuthPage() {
provider,
options: {
skipBrowserRedirect: true,
redirectTo: `http://localhost:${port}`,
redirectTo:
process.env.NODE_ENV === 'production'
? 'readest://auth/callback'
: `http://localhost:${port}`,
},
});
@@ -75,34 +84,54 @@ export default function AuthPage() {
openUrl(data.url);
};
const startOAuthServer = async () => {
const handleOAuthUrl = async (url: string) => {
console.log('Received OAuth URL:', url);
const hashMatch = url.match(/#(.*)/);
if (hashMatch) {
const hash = hashMatch[1];
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const next = params.get('next') ?? '/';
if (accessToken) {
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
}
}
};
const startTauriOAuth = async () => {
try {
const port = await start();
setPort(port);
console.log(`OAuth server started on port ${port}`);
if (process.env.NODE_ENV === 'production') {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
currentWindow.listen('single-instance', ({ event, payload }) => {
console.log('Received deep link:', event, payload);
const { args } = payload as SingleInstancePayload;
if (args?.[1]) {
handleOAuthUrl(args[1]);
}
});
await onOpenUrl((urls) => {
urls.forEach((url) => {
handleOAuthUrl(url);
});
});
} else {
const port = await start();
setPort(port);
console.log(`OAuth server started on port ${port}`);
await onUrl((url) => {
console.log('Received OAuth URL:', url);
const hashMatch = url.match(/#(.*)/);
if (hashMatch) {
const hash = hashMatch[1];
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const next = params.get('next') ?? '/';
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
}
});
await onInvalidUrl((url) => {
console.log('Received invalid OAuth URL:', url);
});
await onUrl(handleOAuthUrl);
await onInvalidUrl((url) => {
console.log('Received invalid OAuth URL:', url);
});
}
} catch (error) {
console.error('Error starting OAuth server:', error);
}
};
const stopOAuthServer = async () => {
const stopTauriOAuth = async () => {
try {
if (port) {
await cancel(port);
@@ -126,10 +155,10 @@ export default function AuthPage() {
if (isOAuthServerRunning.current) return;
isOAuthServerRunning.current = true;
startOAuthServer();
startTauriOAuth();
return () => {
isOAuthServerRunning.current = false;
stopOAuthServer();
stopTauriOAuth();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -156,13 +185,16 @@ export default function AuthPage() {
return null;
}
// For tauri app development, use a custom OAuth server to handle the OAuth callback
// For tauri app production, use deeplink to handle the OAuth callback
// For web app, use the built-in OAuth callback page /auth/callback
return isTauriAppPlatform() ? (
<div className='flex pt-11'>
<button
onClick={handleGoBack}
className='btn btn-ghost fixed left-3 top-11 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack size={20} />
<IoArrowBack size={20} className='text-base-content' />
</button>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<ProviderLogin
@@ -206,7 +238,7 @@ export default function AuthPage() {
onClick={handleGoBack}
className='btn btn-ghost fixed left-10 top-6 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack size={20} />
<IoArrowBack size={20} className='text-base-content' />
</button>
<Auth
supabaseClient={supabase}
@@ -20,11 +20,12 @@ import { navigateToReader } from '@/utils/nav';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import { isTauriAppPlatform } from '@/services/environment';
import BookDetailModal from '@/components/BookDetailModal';
import ReadingProgress from './ReadingProgress';
type BookshelfItem = Book | BooksGroup;
@@ -71,16 +72,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
const [clickedImage, setClickedImage] = useState<string | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const isImportingBook = useRef(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const showMoreDetails = (book: Book) => {
setIsModalOpen(true);
setSelectedBook(book);
const showBookDetailsModal = (book: Book) => {
setShowDetailsBook(book);
};
const closeModal = () => {
setIsModalOpen(false);
const dismissBookDetailsModal = () => {
setShowDetailsBook(null);
};
const { setLibrary } = useLibraryStore();
@@ -154,6 +153,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const openBookMenuItem = await MenuItem.new({
text: isSelectMode ? _('Select Book') : _('Open Book'),
action: async () => {
handleBookClick(book.hash);
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
@@ -161,6 +166,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
revealItemInDir(folder);
},
});
const showBookDetailsMenuItem = await MenuItem.new({
text: _('Show Book Details'),
action: async () => {
showBookDetailsModal(book);
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
@@ -168,6 +179,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
},
});
const menu = await Menu.new();
menu.append(openBookMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
menu.append(deleteBookMenuItem);
menu.popup();
@@ -179,7 +192,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
{bookshelfItems.map((item, index) => (
<div
key={`library-item-${index}`}
className='hover:bg-base-300/50 flex h-full flex-col p-4'
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
>
<div className='flex-grow'>
{'format' in item ? (
@@ -230,17 +243,52 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
)}
</div>
</div>
<div className='card-body flex flex-row items-center justify-between p-0 pt-2'>
<h4 className='card-title line-clamp-1 text-[0.6em] text-xs font-semibold'>
{(item as Book).title}
</h4>
<div
className='card-detail'
role='button'
onClick={showMoreDetails.bind(null, item as Book)}
>
<CiCircleMore size={15} />
<div
className={clsx(
'flex w-full p-0 pt-2',
isWebAppPlatform() ? 'flex-col' : 'flex-row justify-between',
)}
>
<div className='min-w-0 flex-1'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
{(item as Book).title}
</h4>
</div>
{item.progress && (
<div className={'flex items-center justify-between'}>
<ReadingProgress book={item as Book} />
{isWebAppPlatform() && (
<button
type='button'
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={showBookDetailsModal.bind(null, item as Book)}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
showBookDetailsModal(item as Book);
}
}}
>
<CiCircleMore size={15} />
</button>
)}
</div>
)}
{!item.progress && isWebAppPlatform() && (
<div className={'flex items-center justify-end'}>
<button
type='button'
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={showBookDetailsModal.bind(null, item as Book)}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
showBookDetailsModal(item as Book);
}
}}
>
<CiCircleMore size={15} />
</button>
</div>
)}
</div>
</div>
) : (
@@ -256,6 +304,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
className='h-48 w-full object-cover'
/>
</figure>
<div className='card-body p-4'>
<h3 className='card-title line-clamp-2 text-sm'>{book.title}</h3>
<p className='text-neutral-content line-clamp-1 text-xs'>{book.author}</p>
<ReadingProgress book={book} />
</div>
</div>
))}
<h2 className='mb-2 text-lg font-bold'>{(item as BooksGroup).name}</h2>
@@ -295,14 +348,18 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
{showDeleteAlert && (
<Alert
title={_('Confirm Deletion')}
message='Are you sure to delete the selected books?'
message={_('Are you sure to delete the selected books?')}
onClickCancel={() => setShowDeleteAlert(false)}
onClickConfirm={confirmDelete}
/>
)}
{selectedBook && (
<BookDetailModal isOpen={isModalOpen} book={selectedBook} onClose={closeModal} />
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={dismissBookDetailsModal}
/>
)}
</div>
);
@@ -71,7 +71,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</div>
<ul
tabIndex={-1}
className='dropdown-content dropdown-center menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
>
<li>
<button className='text-base-content' onClick={onImportBooks}>
@@ -0,0 +1,43 @@
import type React from 'react';
import { memo, useMemo } from 'react';
import type { Book } from '@/types/book';
interface ReadingProgressProps {
book: Book;
}
const getProgressPercentage = (book: Book) => {
if (!book.progress || !book.progress[1]) {
return null;
}
if (book.progress && book.progress[1] === 1) {
return 100;
}
return Math.round((book.progress[0] / book.progress[1]) * 100);
};
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
({ book }) => {
const progressPercentage = useMemo(() => getProgressPercentage(book), [book]);
if (!progressPercentage) {
return null;
}
return (
<div className='text-neutral-content/70 flex justify-between text-xs'>
<span>{progressPercentage}%</span>
</div>
);
},
(prevProps, nextProps) => {
return (
prevProps.book.hash === nextProps.book.hash &&
prevProps.book.updatedAt === nextProps.book.updatedAt
);
},
);
ReadingProgress.displayName = 'ReadingProgress';
export default ReadingProgress;
+31 -8
View File
@@ -7,8 +7,10 @@ import { useRouter } from 'next/navigation';
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform } from '@/services/environment';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
@@ -20,10 +22,11 @@ import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDemoBooks } from './hooks/useDemoBooks';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import { AboutWindow } from '@/components/AboutWindow';
const LibraryPage = () => {
const router = useRouter();
@@ -46,7 +49,7 @@ const LibraryPage = () => {
useEffect(() => {
const doAppUpdates = async () => {
if (isTauriAppPlatform()) {
if (hasUpdater()) {
await checkForAppUpdates();
}
};
@@ -57,9 +60,14 @@ const LibraryPage = () => {
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const bookIds: string[] = [];
for (const file of openWithFiles) {
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
console.log('Open with book:', file);
try {
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
}
} catch (error) {
console.log('Failed to import book:', file, error);
}
}
setLibrary(libraryBooks);
@@ -149,9 +157,23 @@ const LibraryPage = () => {
const importBooks = async (files: [string | File]) => {
setLoading(true);
const failedFiles = [];
for (const file of files) {
await appService?.importBook(file, libraryBooks);
setLibrary(libraryBooks);
try {
await appService?.importBook(file, libraryBooks);
setLibrary(libraryBooks);
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getBaseFilename(filename);
failedFiles.push(baseFilename);
eventDispatcher.dispatch('toast', {
message: _('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}),
type: 'error',
});
console.error('Failed to import book:', filename, error);
}
}
appService?.saveLibraryBooks(libraryBooks);
setLoading(false);
@@ -253,6 +275,7 @@ const LibraryPage = () => {
</div>
))}
<AboutWindow />
<Toast />
</div>
);
};
@@ -1,7 +1,9 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSidebarStore } from '@/store/sidebarStore';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
@@ -11,7 +13,6 @@ import PageInfoView from './PageInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import { useBookDataStore } from '@/store/bookDataStore';
import FootnotePopup from './FootnotePopup';
interface BooksGridProps {
@@ -22,9 +23,18 @@ interface BooksGridProps {
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
useEffect(() => {
if (!sideBarBookKey) return;
const bookData = getBookData(sideBarBookKey);
if (!bookData || !bookData.book) return;
document.title = bookData.book.title;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
return (
<div
className='grid h-full flex-grow'
@@ -49,7 +59,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className='relative h-full w-full overflow-hidden'
className='rounded-window relative h-full w-full overflow-hidden'
>
{isBookmarked && <Ribbon width={marginGap} />}
<HeaderBar
@@ -18,7 +18,6 @@ import {
handleClick,
handleWheel,
} from '../utils/iframeEventHandlers';
import Toast from '@/components/Toast';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -39,7 +38,7 @@ const FoliateViewer: React.FC<{
return () => clearTimeout(timer);
}, [toastMessage]);
useProgressSync(bookKey, setToastMessage);
useProgressSync(bookKey);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -164,9 +163,6 @@ const FoliateViewer: React.FC<{
onClick={(event) => handleTurnPage(event)}
ref={containerRef}
/>
{toastMessage && (
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
)}
</>
);
};
@@ -2,10 +2,12 @@ import React from 'react';
import clsx from 'clsx';
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import Button from '@/components/Button';
interface FooterBarProps {
@@ -16,9 +18,10 @@ interface FooterBarProps {
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const _ = useTranslation();
const { hoveredBookKey, setHoveredBookKey, getView } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const view = getView(bookKey);
const progress = getProgress(bookKey);
const handleProgressChange = (event: React.ChangeEvent) => {
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
@@ -41,6 +44,16 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
view?.history.forward();
};
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey, range });
}
};
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
return (
@@ -83,6 +96,7 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
value={pageinfoValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button icon={<FaHeadphones size={20} />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={<RiArrowRightWideLine size={20} />}
onClick={handleGoNext}
@@ -28,6 +28,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const { getView, getViewSettings } = useReaderStore();
const { themeCode } = useTheme();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const footnoteHandler = new FootnoteHandler();
useEffect(() => {
@@ -73,7 +74,13 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const rect = gridFrame.getBoundingClientRect();
const viewSettings = getViewSettings(bookKey)!;
const triangPos = getPosition(detail.a, rect, viewSettings.vertical);
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
@@ -107,6 +114,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
}
}, [footnoteRef]);
const width = viewSettings.vertical ? popupHeight : popupWidth;
const height = viewSettings.vertical ? popupWidth : popupHeight;
return (
<div>
{showPopup && (
@@ -117,8 +127,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
/>
)}
<Popup
width={popupWidth}
height={popupHeight}
width={width}
height={height}
position={showPopup ? popupPosition! : undefined}
trianglePosition={showPopup ? trianglePosition! : undefined}
className='select-text overflow-y-auto'
@@ -127,8 +137,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
className=''
ref={footnoteRef}
style={{
width: `${popupWidth}px`,
height: `${popupHeight}px`,
width: `${width}px`,
height: `${height}px`,
}}
></div>
</Popup>
@@ -5,10 +5,10 @@ import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import ReaderContent from './ReaderContent';
import { AboutWindow } from '@/components/AboutWindow';
import { useSettingsStore } from '@/store/settingsStore';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
@@ -33,10 +33,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
return (
library.length > 0 &&
settings.globalReadSettings && (
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
<div className='reader-page rounded-window bg-base-100 text-base-content min-h-screen select-none'>
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<Toast />
</Suspense>
</div>
)
@@ -9,21 +9,24 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { Book } from '@/types/book';
import { SystemSettings } from '@/types/settings';
import { parseOpenWithFiles } from '@/helpers/cli';
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
import { isTauriAppPlatform } from '@/services/environment';
import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { navigateToLibrary } from '@/utils/nav';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
import useBooksManager from '../hooks/useBooksManager';
import useBookShortcuts from '../hooks/useBookShortcuts';
import BookDetailModal from '@/components/BookDetailModal';
import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import { eventDispatcher } from '@/utils/event';
import TTSControl from './tts/TTSControl';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
@@ -35,6 +38,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { getConfig, getBookData, saveConfig } = useBookDataStore();
const { getView, setBookKeys } = useReaderStore();
const { initViewState, getViewState, clearViewState } = useReaderStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const isInitiating = useRef(false);
const [loading, setLoading] = useState(false);
@@ -59,6 +63,13 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (index === 0) setSideBarBookKey(key);
}
});
const handleShowBookDetails = (event: CustomEvent) => {
const book = event.detail as Book;
setShowDetailsBook(book);
return true;
};
eventDispatcher.onSync('show-book-details', handleShowBookDetails);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -78,14 +89,20 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { book } = getBookData(bookKey) || {};
const { isPrimary } = getViewState(bookKey) || {};
if (isPrimary && book && config) {
const settings = useSettingsStore.getState().settings;
await saveConfig(envConfig, bookKey, config, settings);
}
};
const saveConfigAndCloseBook = async (bookKey: string) => {
console.log('Closing book', bookKey);
getView(bookKey)?.close();
getView(bookKey)?.remove();
try {
getView(bookKey)?.close();
getView(bookKey)?.remove();
} catch {
console.info('Error closing book', bookKey);
}
eventDispatcher.dispatch('tts-stop', { bookKey });
await saveBookConfig(bookKey);
clearViewState(bookKey);
};
@@ -96,6 +113,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
};
const handleCloseBooks = async () => {
const settings = useSettingsStore.getState().settings;
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
await saveSettings(envConfig, settings);
};
@@ -138,7 +156,15 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
<div className='flex h-screen'>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<TTSControl />
<Notebook />
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={() => setShowDetailsBook(null)}
/>
)}
</div>
);
};
@@ -86,7 +86,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
return (
<div
tabIndex={0}
className='view-menu dropdown-content dropdown-right no-triangle border-base-100 z-20 mt-1 w-72 border shadow-2xl'
className='view-menu dropdown-content bgcolor-base-200 dropdown-right no-triangle border-base-200 z-20 mt-1 w-72 border shadow-2xl'
>
<div className={clsx('flex items-center justify-between rounded-md')}>
<button
@@ -6,7 +6,8 @@ import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { SiDeepl } from 'react-icons/si';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
@@ -17,17 +18,18 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import Toast from '@/components/Toast';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import DeepLPopup from './DeepLPopup';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
@@ -54,7 +56,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
const [toastMessage, setToastMessage] = useState('');
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
@@ -82,7 +83,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelection({ key: bookKey, text: sel.toString(), range: sel.getRangeAt(0), index });
}
};
detail.doc?.addEventListener('pointerup', handlePointerup);
if (bookData.book?.format !== 'PDF') {
detail.doc?.addEventListener('pointerup', handlePointerup);
}
};
const onDrawAnnotation = (event: Event) => {
@@ -191,11 +194,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
}, [toastMessage]);
useEffect(() => {
if (!progress) return;
const { location } = progress;
@@ -220,7 +218,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleCopy = () => {
if (!selection || !selection.text) return;
setToastMessage('Copied to notebook');
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to notebook'),
className: 'whitespace-nowrap',
timeout: 2000,
});
const { booknotes: annotations = [] } = config;
if (selection) navigator.clipboard.writeText(selection.text);
@@ -332,19 +335,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowDeepLPopup(true);
};
const handleSpeakText = async () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
const selectionAnnotated = selection?.annotated;
const buttons = [
{ tooltipText: 'Copy', Icon: FiCopy, onClick: handleCopy },
{ tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy },
{
tooltipText: selectionAnnotated ? 'Delete Highlight' : 'Highlight',
tooltipText: selectionAnnotated ? _('Delete Highlight') : _('Highlight'),
Icon: selectionAnnotated ? RiDeleteBinLine : PiHighlighterFill,
onClick: handleHighlight,
},
{ tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: 'Search', Icon: FiSearch, onClick: handleSearch },
{ tooltipText: 'Dictionary', Icon: BsTranslate, onClick: handleDictionary },
{ tooltipText: 'Translate', Icon: SiDeepl, onClick: handleTranslation },
{ tooltipText: 'Wikipedia', Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Annotate'), Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: _('Search'), Icon: FiSearch, onClick: handleSearch },
{ tooltipText: _('Dictionary'), Icon: TbHexagonLetterD, onClick: handleDictionary },
{ tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation },
{ tooltipText: _('Speak'), Icon: FaHeadphones, onClick: handleSpeakText },
];
return (
@@ -399,8 +409,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onHighlight={handleHighlight}
/>
)}
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
</div>
);
};
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
const LANGUAGES = {
@@ -37,6 +38,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
popupWidth,
popupHeight,
}) => {
const _ = useTranslation();
const { settings, setSettings } = useSettingsStore();
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
@@ -124,7 +126,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
>
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-base font-semibold'>Original Text</h1>
<h1 className='text-base font-semibold'>{_('Original Text')}</h1>
<select
value={sourceLang}
onChange={handleSourceLangChange}
@@ -134,7 +136,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
return (
<option key={code} value={code}>
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} (detected)`
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} ` + _('(detected)')
: name}
</option>
);
@@ -146,7 +148,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
<div className='text-neutral-content relative h-[50%] overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-base font-semibold'>Translated Text</h2>
<h2 className='text-base font-semibold'>{_('Translated Text')}</h2>
<select
value={targetLang}
onChange={handleTargetLangChange}
@@ -163,7 +165,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
</div>
{loading ? (
<p className='text-base italic text-gray-500'>Loading...</p>
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
) : error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
@@ -51,7 +51,8 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
>
<div
className={clsx(
'h-4 w-4 p-0 text-center leading-none',
'w-4 p-0 text-center leading-none',
style === 'highlight' ? 'h-4' : 'h-5',
style === 'highlight' &&
(selectedStyle === 'highlight' ? `bg-${selectedColor}-400` : `bg-gray-300`),
(style === 'underline' || style === 'squiggly') &&
@@ -6,7 +6,7 @@ import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
interface NoteEditorProps {
onSave: (selction: TextSelection, note: string) => void;
onSave: (selection: TextSelection, note: string) => void;
onEdit: (annotation: BookNote) => void;
}
@@ -57,7 +57,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
className={clsx(
'note-editor textarea textarea-ghost min-h-[1em] resize-none !outline-none',
'inset-0 w-full rounded-none border-0 bg-transparent p-0 leading-normal',
'text-base',
'text-sm',
)}
ref={editorRef}
value={note}
@@ -70,7 +70,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1.5em] min-h-[1.5em] items-end p-0',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={handleSaveNote}
@@ -160,21 +160,15 @@ const Notebook: React.FC = ({}) => {
>
<p className='line-clamp-1'>{item.text || `Excerpt ${index + 1}`}</p>
</div>
<div
className='collapse-content select-text px-3 pb-0 text-xs'
onClick={(e) => e.stopPropagation()}
>
<div className='collapse-content select-text px-3 pb-0 text-xs'>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end'>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1em] min-h-[1em] items-end p-0',
)}
<div
className='cursor-pointer align-bottom text-xs text-red-400'
onClick={handleEditNote.bind(null, item, true)}
>
<div className='align-bottom text-xs text-red-400'>{_('Delete')}</div>
</button>
{_('Delete')}
</div>
</div>
</div>
</div>
@@ -1,11 +1,14 @@
import clsx from 'clsx';
import React from 'react';
import { FiChevronDown } from 'react-icons/fi';
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
interface DropdownProps {
family?: string;
selected: string;
options: string[];
moreOptions?: string[];
onSelect: (option: string) => void;
onGetFontFamily: (option: string, family: string) => string;
}
@@ -14,21 +17,23 @@ const FontDropdown: React.FC<DropdownProps> = ({
family,
selected,
options,
moreOptions,
onSelect,
onGetFontFamily,
}) => {
const _ = useTranslation();
return (
<div className='dropdown dropdown-end'>
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
>
<span style={{ fontFamily: onGetFontFamily(selected, family ?? '') }}>{selected}</span>
<FiChevronDown className='h-4 w-4' />
<FiChevronUp className='h-4 w-4' />
</button>
<ul
tabIndex={0}
className='dropdown-content dropdown-right menu bg-base-100 rounded-box z-[1] mt-4 w-44 shadow'
className='dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-0 z-[1] mt-4 w-44 shadow'
>
{options.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
@@ -40,6 +45,36 @@ const FontDropdown: React.FC<DropdownProps> = ({
</div>
</li>
))}
{moreOptions && moreOptions.length > 0 && (
<li className='dropdown dropdown-left dropdown-top'>
<div className='flex items-center px-0'>
<span style={{ minWidth: '20px' }}>
<FiChevronLeft className='h-4 w-4' />
</span>
<span>{_('System Fonts')}</span>
</div>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] overflow-y-scroll shadow',
'!mr-5 mb-[-46px] inline max-h-80 w-[250px] overflow-y-scroll',
)}
>
{moreOptions.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-2'>
<span style={{ minWidth: '20px' }}>
{selected === option && <MdCheck size={20} className='text-base-content' />}
</span>
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>
{option}
</span>
</div>
</li>
))}
</ul>
</li>
)}
</ul>
</div>
);
@@ -3,18 +3,27 @@ import React, { useEffect, useState } from 'react';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
import { MONOSPACE_FONTS, SANS_SERIF_FONTS, SERIF_FONTS } from '@/services/constants';
import {
LINUX_FONTS,
MACOS_FONTS,
MONOSPACE_FONTS,
SANS_SERIF_FONTS,
SERIF_FONTS,
WINDOWS_FONTS,
} from '@/services/constants';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getOSPlatform } from '@/utils/misc';
interface FontFaceProps {
className?: string;
family: string;
label: string;
options: string[];
moreOptions?: string[];
selected: string;
onSelect: (option: string) => void;
}
@@ -25,12 +34,21 @@ const handleFontFaceFont = (option: string, family: string) => {
return `'${option}', ${family}`;
};
const FontFace = ({ className, family, label, options, selected, onSelect }: FontFaceProps) => (
const FontFace = ({
className,
family,
label,
options,
moreOptions,
selected,
onSelect,
}: FontFaceProps) => (
<div className={clsx('config-item', className)}>
<span className=''>{label}</span>
<FontDropdown
family={family}
options={options}
moreOptions={moreOptions}
selected={selected}
onSelect={onSelect}
onGetFontFamily={handleFontFaceFont}
@@ -46,6 +64,21 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const osPlatform = getOSPlatform();
let moreFonts: string[] = [];
switch (osPlatform) {
case 'macos':
moreFonts = MACOS_FONTS;
break;
case 'windows':
moreFonts = WINDOWS_FONTS;
break;
case 'linux':
moreFonts = LINUX_FONTS;
break;
default:
break;
}
const [defaultFontSize, setDefaultFontSize] = useState(viewSettings.defaultFontSize!);
const [minFontSize, setMinFontSize] = useState(viewSettings.minimumFontSize!);
const [overrideFont, setOverrideFont] = useState(viewSettings.overrideFont!);
@@ -206,6 +239,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='serif'
label={_('Serif Font')}
options={SERIF_FONTS}
moreOptions={moreFonts}
selected={serifFont}
onSelect={setSerifFont}
/>
@@ -213,6 +247,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='sans-serif'
label={_('Sans-Serif Font')}
options={SANS_SERIF_FONTS}
moreOptions={moreFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
/>
@@ -221,6 +256,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='monospace'
label={_('Monospace Font')}
options={MONOSPACE_FONTS}
moreOptions={moreFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
/>
@@ -1,17 +1,24 @@
import React, { useEffect, useState } from 'react';
import { MdOutlineAutoMode } from 'react-icons/md';
import { MdOutlineTextRotationDown, MdOutlineTextRotationNone } from 'react-icons/md';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import NumberInput from './NumberInput';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getBookLangCode } from '@/utils/book';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
@@ -23,6 +30,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
useEffect(() => {
viewSettings.lineHeight = lineHeight;
@@ -111,8 +119,66 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
useEffect(() => {
viewSettings.maxBlockSize = maxBlockSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxBlockSize = maxBlockSize;
setSettings(settings);
}
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxBlockSize]);
useEffect(() => {
// global settings are not supported for writing mode
viewSettings.writingMode = writingMode;
setViewSettings(bookKey, viewSettings);
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writingMode]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
return (
<div className='my-4 w-full space-y-6'>
{isCJKBook && (
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<div className='flex gap-2'>
<div className='tooltip tooltip-bottom' data-tip={_('Default')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('auto')}
>
<MdOutlineAutoMode size={20} />
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip={_('Horizontal Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-tb')}
>
<MdOutlineTextRotationNone size={20} />
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip={_('Vertical Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('vertical-rl')}
>
<MdOutlineTextRotationDown size={20} />
</button>
</div>
</div>
</div>
</div>
)}
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
@@ -173,7 +239,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
value={maxColumnCount}
onChange={setMaxColumnCount}
min={1}
max={2}
max={4}
/>
<NumberInput
label={_('Maximum Inline Size')}
@@ -1,13 +1,12 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import cssbeautify from 'cssbeautify';
import { getStyles } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
const cssRegex =
/((?:\s*)([\w#.@*,:\-.:>+~$$$$\"=(),*\s]+)\s*{(?:[\s]*)((?:[^\}]+[:][^\}]+;?)*)*\s*}(?:\s*))/gim;
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -18,44 +17,51 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [userStylesheet, setUserStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [error, setError] = useState<string | null>(null);
let cssInput = userStylesheet;
const validateCSS = (css: string) => {
return cssRegex.test(css);
};
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
cssInput = e.target.value;
const cssInput = e.target.value;
setDraftStylesheet(cssInput);
setDraftStylesheetSaved(false);
try {
const formattedCSS = cssbeautify(cssInput, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setUserStylesheet(formattedCSS);
if (cssInput && !validateCSS(cssInput)) {
throw new Error('Invalid CSS');
const { isValid, error } = cssValidate(cssInput);
if (cssInput && !isValid) {
throw new Error(error || 'Invalid CSS');
}
setError(null);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Invalid CSS: Please check your input.');
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
} catch (err) {
setError('Invalid CSS: Please check your input.');
console.log('CSS Error:', err);
}
};
const applyStyles = () => {
const formattedCSS = cssbeautify(draftStylesheet, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setDraftStylesheet(formattedCSS);
setDraftStylesheetSaved(true);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
};
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
@@ -90,10 +96,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card bg-base-100 border shadow'>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className='text-gray-700'>{_('Paging Animation')}</span>
<span className=''>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
@@ -107,10 +113,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card bg-base-100 border shadow'>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className='text-gray-700'>{_('Disable Click-to-Flip')}</span>
<span className=''>{_('Disable Click-to-Flip')}</span>
<input
type='checkbox'
className='toggle'
@@ -124,22 +130,34 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Custom CSS')}</h2>
<div className={`card bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}>
<div className='divide-y'>
<div className='css-text-area config-item-top config-item-bottom p-1'>
<textarea
className='textarea textarea-ghost h-48 w-full border-0 p-3 !outline-none'
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={cssInput}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onChange={handleUserStylesheetChange}
/>
</div>
<div
className={`card border-base-200 bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}
>
<div className='relative p-1'>
<textarea
className='textarea textarea-ghost h-48 w-full border-0 p-3 !outline-none'
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={draftStylesheet}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onChange={handleUserStylesheetChange}
/>
<button
className={clsx(
'btn btn-ghost bg-base-200 absolute bottom-2 right-4 h-8 min-h-8 px-4 py-2',
draftStylesheetSaved ? 'hidden' : '',
error ? 'btn-disabled' : '',
)}
onClick={applyStyles}
disabled={!!error}
>
{_('Apply')}
</button>
</div>
</div>
{error && <p className='mt-1 text-sm text-red-500'>{error}</p>}
</div>
</div>
);
@@ -25,11 +25,17 @@ const NumberInput: React.FC<NumberInputProps> = ({
const numberStep = step || 1;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === '' ? 0 : parseFloat(e.target.value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
const value = e.target.value;
// Allow empty string or valid numbers without leading zeros
if (value === '' || /^[1-9]\d*\.?\d*$|^0?\.?\d*$/.test(value)) {
const newValue = value === '' ? 0 : parseFloat(value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
}
}
};
@@ -59,6 +65,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
<div className='text-base-content flex items-center gap-2'>
<input
type='text'
inputMode='decimal'
value={localValue}
onChange={handleChange}
onBlur={handleOnBlur}
@@ -38,8 +38,8 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
}, []);
return (
<dialog className='modal modal-open min-w-90 w-full'>
<div className='modal-box settings-content flex h-[60%] w-1/2 min-w-[480px] max-w-full flex-col p-0'>
<dialog className='modal modal-open min-w-90 w-full !bg-[rgba(0,0,0,0.2)]'>
<div className='modal-box settings-content flex h-[65%] w-1/2 min-w-[480px] max-w-full flex-col p-0'>
<div className='dialog-header bg-base-100 sticky top-0 z-10 flex items-center justify-center px-4 pt-2'>
<div className='dialog-tabs flex h-10 max-w-[80%] flex-grow items-center justify-around'>
<button
@@ -73,7 +73,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
</div>
<div className='flex h-full items-center justify-end'>
<Dropdown
className='dropdown-bottom dropdown-end absolute right-[7%]'
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={16} />}
>
@@ -1,20 +1,22 @@
import React from 'react';
import Image from 'next/image';
import { MdInfoOutline } from 'react-icons/md';
import { Book } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
interface BookCardProps {
cover: string;
title: string;
author: string;
}
const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
const BookCard = ({ book }: { book: Book }) => {
const { coverImageUrl, title, author } = book;
const _ = useTranslation();
const showBookDetails = () => {
eventDispatcher.dispatchSync('show-book-details', book);
};
return (
<div className='flex h-20 w-full items-center'>
<Image
src={cover}
src={coverImageUrl!}
alt={_('Book Cover')}
width={56}
height={80}
@@ -31,7 +33,7 @@ const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
aria-label={_('More Info')}
>
<MdInfoOutline size={18} className='fill-base-content' />
<MdInfoOutline size={18} className='fill-base-content' onClick={showBookDetails} />
</button>
</div>
);
@@ -122,7 +122,12 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
)}
onClick={editNote.bind(null, item)}
>
<div className='align-bottom text-blue-400'>{_('Edit')}</div>
<div className={clsx(
'align-bottom text-blue-400',
'transition duration-300 ease-in-out',
'group-hover:opacity-100 opacity-0',
'hover:text-blue-600',
)}>{_('Edit')}</div>
</button>
)}
<button
@@ -132,7 +137,12 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
)}
onClick={deleteNote.bind(null, item)}
>
<div className='align-bottom text-red-400'>{_('Delete')}</div>
<div className={clsx(
'align-bottom text-red-400',
'transition duration-300 ease-in-out',
'group-hover:opacity-100 opacity-0',
'hover:text-red-600',
)}>{_('Delete')}</div>
</button>
</div>
</div>
@@ -15,6 +15,8 @@ const SidebarContent: React.FC<{
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
const [fade, setFade] = useState(false);
const [targetTab, setTargetTab] = useState(activeTab);
useEffect(() => {
const container = scrollContainerRef.current;
@@ -49,10 +51,17 @@ const SidebarContent: React.FC<{
}, [sideBarBookKey]);
const handleTabChange = (tab: string) => {
setFade(true);
const timeout = setTimeout(() => {
setFade(false)
setTargetTab(tab);
setConfig(sideBarBookKey!, config);
clearTimeout(timeout);
}, 300);
setActiveTab(tab);
const config = getConfig(sideBarBookKey!)!;
config.viewSettings!.sideBarTab = tab;
setConfig(sideBarBookKey!, config);
};
return (
@@ -63,14 +72,20 @@ const SidebarContent: React.FC<{
'font-sans text-sm font-normal shadow-inner',
)}
>
<div ref={scrollContainerRef} className='scroll-container overflow-y-auto'>
{activeTab === 'toc' && bookDoc.toc && (
<div
ref={scrollContainerRef}
className={clsx(
'scroll-container overflow-y-auto transition-opacity duration-300 ease-in-out',
{ 'opacity-0': fade, 'opacity-100': !fade }
)}
>
{targetTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
)}
{activeTab === 'annotations' && (
{targetTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
{activeTab === 'bookmarks' && (
{targetTab === 'bookmarks' && (
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
</div>
@@ -10,7 +10,8 @@ import { BookSearchConfig, BookSearchResult } from '@/types/book';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
const MINIMUM_SEARCH_TERM_LENGTH = 2;
const MINIMUM_SEARCH_TERM_LENGTH_DEFAULT = 2;
const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
interface SearchBarProps {
isVisible: boolean;
@@ -97,8 +98,15 @@ const SearchBar: React.FC<SearchBarProps> = ({
handleSearchTermChange(searchTerm);
};
const exceedMinSearchTermLength = (searchTerm: string) => {
const isCJK = /[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/.test(searchTerm);
const minLength = isCJK ? MINIMUM_SEARCH_TERM_LENGTH_CJK : MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
return searchTerm.length >= minLength;
};
const handleSearchTermChange = (term: string) => {
if (term.length >= MINIMUM_SEARCH_TERM_LENGTH) {
if (exceedMinSearchTermLength(term)) {
handleSearch(term);
} else {
resetSearch();
@@ -118,7 +126,10 @@ const SearchBar: React.FC<SearchBarProps> = ({
onSearchResultChange([...results]);
isSearchPending.current = false;
console.log('search done');
if (queuedSearchTerm.current !== term && queuedSearchTerm.current.length > 2) {
if (
queuedSearchTerm.current !== term &&
exceedMinSearchTermLength(queuedSearchTerm.current)
) {
handleSearch(queuedSearchTerm.current);
}
}
@@ -26,7 +26,7 @@ const SearchResultItem: React.FC<SearchResultItemProps> = ({
ref={viewRef}
className={clsx(
'my-2 cursor-pointer rounded-lg p-2 text-sm',
isCurrent ? 'bg-base-300 hover:bg-gray-300/70' : 'hover:bg-base-300 bg-white',
isCurrent ? 'bg-base-300 hover:bg-gray-300/70' : 'hover:bg-base-300 bg-base-100',
)}
onClick={() => onSelectResult(cfi)}
>
@@ -125,7 +125,7 @@ const SideBar: React.FC<{
/>
</div>
<div className='border-base-300/50 border-b px-3'>
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
<BookCard book={book} />
</div>
</div>
{isSearchBarVisible && searchResults ? (
@@ -126,7 +126,7 @@ const TOCView: React.FC<{
const rect = currentItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(currentItem as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
(currentItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
@@ -1,23 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import { MdToc, MdEditNote, MdBookmarkBorder } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
const TabNavigation: React.FC<{
activeTab: string;
onTabChange: (tab: string) => void;
}> = ({ activeTab, onTabChange }) => (
<div className='bottom-tab border-base-300/50 flex w-full border-t'>
{['toc', 'annotations', 'bookmarks'].map((tab) => (
<button
key={tab}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === tab ? 'bg-base-300' : ''}`}
onClick={() => onTabChange(tab)}
>
{tab === 'toc' && <MdToc size={20} className='mx-auto' />}
{tab === 'annotations' && <MdEditNote size={20} className='mx-auto' />}
{tab === 'bookmarks' && <MdBookmarkBorder size={20} className='mx-auto' />}
</button>
))}
</div>
);
}> = ({ activeTab, onTabChange }) => {
const _ = useTranslation();
const tabs = ['toc', 'annotations', 'bookmarks'];
return (
<div className="bottom-tab border-base-300/50 flex w-full border-t relative">
<div
className={clsx(
'absolute bottom-0 left-0 h-full w-1/3 bg-base-300 -z-10',
'transition-transform duration-300 transform',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-full',
activeTab === 'bookmarks' && 'translate-x-[200%]',
)}
/>
{tabs.map((tab) => (
<div
key={tab}
className='tooltip z-50 tooltip-top m-1.5 flex-1 rounded-md p-2 cursor-pointer'
data-tip={tab === 'toc' ? _('Table of Contents') : tab === 'annotations' ? _('Annotate') : _('Bookmark')}
>
<div
className={clsx(
'',
)}
onClick={() => onTabChange(tab)}
>
{
tab === 'toc' ? (
<MdToc size={20} className='mx-auto' />
) : tab === 'annotations' ? (
<MdEditNote size={20} className='mx-auto' />
) : (
<MdBookmarkBorder size={20} className='mx-auto' />
)
}
</div>
</div>
))}
</div>
)
};
export default TabNavigation;
@@ -0,0 +1,283 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TTSController } from '@/services/tts/TTSController';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/ui';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
const POPUP_WIDTH = 282;
const POPUP_HEIGHT = 160;
const POPUP_PADDING = 10;
const TTSControl = () => {
const _ = useTranslation();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showIndicator, setShowIndicator] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [panelPosition, setPanelPosition] = useState<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
useEffect(() => {
return () => {
if (ttsControllerRef.current) {
ttsControllerRef.current.kill();
ttsControllerRef.current = null;
}
};
}, []);
useEffect(() => {
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
eventDispatcher.onSync('tts-is-speaking', handleQueryIsSpeaking);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
eventDispatcher.offSync('tts-is-speaking', handleQueryIsSpeaking);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey, range } = event.detail;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !viewSettings || !bookData) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
type: 'warning',
});
return;
}
setBookKey(bookKey);
if (ttsControllerRef.current) {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
setShowIndicator(true);
try {
const ttsController = new TTSController(view);
await ttsController.init();
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
if (ssml) {
ttsController.setRate(viewSettings.ttsRate);
ttsController.setVoice(viewSettings.ttsVoice);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
setIsPlaying(true);
}
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
type: 'error',
});
console.error(error);
}
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey: stopBookKey } = event.detail;
if (bookKey === stopBookKey) {
handleStop();
}
};
const handleQueryIsSpeaking = () => {
return !!ttsControllerRef.current;
};
const handleTogglePlay = async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
if (isPlaying) {
setIsPlaying(false);
setIsPaused(true);
await ttsController.pause();
} else if (isPaused) {
setIsPlaying(true);
setIsPaused(false);
// start for forward/backward/setvoice-paused
// set rate don't pause the tts
if (ttsController.state === 'paused') {
await ttsController.resume();
} else {
await ttsController.start();
}
}
};
const handleBackward = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward();
}
};
const handleForward = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward();
}
};
const handleStop = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.stop();
ttsControllerRef.current = null;
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
}
};
// rate range: 0.5 - 3, 1.0 is normal speed
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetRate = useCallback(
throttle(async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setRate(rate);
await ttsController.start();
} else {
await ttsController.setRate(rate);
}
}
}, 3000),
[],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetVoice = useCallback(
throttle(async (voice: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setVoice(voice);
await ttsController.start();
} else {
await ttsController.setVoice(voice);
}
}
}, 3000),
[],
);
const handleGetVoices = async (lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoices(lang);
}
return [];
};
const handleGetVoiceId = () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoiceId();
}
return '';
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const windowRect = document.documentElement.getBoundingClientRect();
const trianglePos = {
dir: 'up',
point: { x: rect.left + rect.width / 2, y: rect.top - 12 },
} as Position;
const popupPos = getPopupPosition(
trianglePos,
windowRect,
POPUP_WIDTH,
POPUP_HEIGHT,
POPUP_PADDING,
);
setPanelPosition(popupPos);
setTrianglePosition(trianglePos);
}
};
const togglePopup = () => {
updatePanelPosition();
setShowPanel((prev) => !prev);
};
const handleDismissPopup = () => {
setShowPanel(false);
};
return (
<div>
{showPanel && (
<div
className='fixed inset-0'
onClick={handleDismissPopup}
onContextMenu={handleDismissPopup}
/>
)}
{showIndicator && (
<div ref={iconRef} className='absolute bottom-12 right-6 h-12 w-12'>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
</div>
)}
{showPanel && panelPosition && trianglePosition && (
<Popup
width={POPUP_WIDTH}
height={POPUP_HEIGHT}
position={panelPosition}
trianglePosition={trianglePosition}
className='bg-base-200 absolute flex shadow-lg'
>
<TTSPanel
bookKey={bookKey}
ttsLang={ttsLang}
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
onBackward={handleBackward}
onForward={handleForward}
onStop={handleStop}
onSetRate={handleSetRate}
onGetVoices={handleGetVoices}
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
/>
</Popup>
)}
</div>
);
};
export default TTSControl;
@@ -0,0 +1,54 @@
import React from 'react';
type TTSIconProps = {
isPlaying: boolean;
onClick: () => void;
};
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, onClick }) => {
const bars = [1, 2, 3, 4];
return (
<div className='relative h-full w-full cursor-pointer' onClick={onClick}>
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
<div
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
style={{
animation: isPlaying ? 'moveGradient 2s alternate infinite' : 'none',
}}
/>
</div>
<div className='absolute inset-0 flex items-center justify-center'>
<style>{`
@keyframes moveGradient {
0% { transform: translate(0, 0); }
100% { transform: translate(25%, 25%); }
}
@keyframes bounce {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.6); }
}
`}</style>
<div className='flex items-end space-x-1'>
{bars.map((bar) => (
<div
key={bar}
className='w-1 rounded-t bg-white'
style={{
height: '16px',
animationName: isPlaying ? 'bounce' : 'none',
animationDuration: isPlaying ? `${1 + bar * 0.1}s` : '0s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: `${bar * 0.1}s`,
}}
/>
))}
</div>
</div>
</div>
);
};
export default TTSIcon;
@@ -0,0 +1,161 @@
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoice } from '@/services/tts';
type TTSPanelProps = {
bookKey: string;
ttsLang: string;
isPlaying: boolean;
onTogglePlay: () => void;
onBackward: () => void;
onForward: () => void;
onStop: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string) => void;
onGetVoiceId: () => string;
};
const TTSPanel = ({
bookKey,
ttsLang,
isPlaying,
onTogglePlay,
onBackward,
onForward,
onStop,
onSetRate,
onGetVoices,
onSetVoice,
onGetVoiceId,
}: TTSPanelProps) => {
const _ = useTranslation();
const { getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [voices, setVoices] = useState<TTSVoice[]>([]);
const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0);
const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? '');
const handleSetRate = (e: ChangeEvent<HTMLInputElement>) => {
let newRate = parseFloat(e.target.value);
newRate = Math.max(0.2, Math.min(3.0, newRate));
setRate(newRate);
onSetRate(newRate);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsRate = newRate;
setViewSettings(bookKey, viewSettings);
};
const handleSelectVoice = (voice: string) => {
onSetVoice(voice);
setSelectedVoice(voice);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsVoice = voice;
setViewSettings(bookKey, viewSettings);
};
useEffect(() => {
const voiceId = onGetVoiceId();
setSelectedVoice(voiceId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const fetchVoices = async () => {
const voices = await onGetVoices(ttsLang);
setVoices(voices);
};
fetchVoices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsLang]);
return (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
className='range'
type='range'
min={0.0}
max={3.0}
step='0.1'
value={rate}
onChange={handleSetRate}
/>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
</div>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>{_('Slow')}</span>
<span className='text-center'></span>
<span className='text-center'>1.0</span>
<span className='text-center'>1.5</span>
<span className='text-center'>2.0</span>
<span className='text-center'></span>
<span className='text-center'>{_('Fast')}</span>
</div>
</div>
<div className='flex items-center justify-between space-x-2'>
<button onClick={onBackward} className='hover:bg-base-200/75 rounded-full p-1'>
<MdFastRewind size={32} />
</button>
<button onClick={onTogglePlay} className='hover:bg-base-200/75 rounded-full p-1'>
{isPlaying ? (
<MdPauseCircle size={48} className='fill-primary' />
) : (
<MdPlayCircle size={48} className='fill-primary' />
)}
</button>
<button onClick={onForward} className='hover:bg-base-200/75 rounded-full p-1'>
<MdFastForward size={32} />
</button>
<button onClick={onStop} className='hover:bg-base-200/75 rounded-full p-1'>
<MdStop size={32} />
</button>
<div className='dropdown dropdown-top'>
<button tabIndex={0} className='hover:bg-base-200/75 rounded-full p-1'>
<RiVoiceAiFill size={32} />
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
)}
>
{voices.map((voice, index) => (
<li
key={`${index}-${voice.id}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: '20px' }}>
{selectedVoice === voice.id && (
<MdCheck size={20} className='text-base-content' />
)}
</span>
<span className={clsx('text-sm', voice.disabled && 'text-gray-400')}>
{voice.name}
</span>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
);
};
export default TTSPanel;
@@ -7,12 +7,10 @@ import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
export const useProgressSync = (
bookKey: string,
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
) => {
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
const { getConfig, setConfig } = useBookDataStore();
const { getView } = useReaderStore();
@@ -21,6 +19,8 @@ export const useProgressSync = (
const { user } = useAuth();
const view = getView(bookKey);
const config = getConfig(bookKey);
// flag to prevent accidental sync without first pulling the config
const configSynced = useRef(false);
const pushConfig = (bookKey: string, config: BookConfig | null) => {
if (!config || !user) return;
@@ -31,13 +31,26 @@ export const useProgressSync = (
);
syncConfigs([compressedConfig], bookHash, 'push');
};
useEffect(() => {
const pullConfig = (bookKey: string) => {
if (!user) return;
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
return () => {
};
const syncConfig = () => {
if (!configSynced.current) {
pullConfig(bookKey);
} else {
pushConfig(bookKey, config);
}
};
useEffect(() => {
if (!user) return;
pullConfig(bookKey);
return () => {
if (configSynced.current) {
pushConfig(bookKey, config);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -46,18 +59,19 @@ export const useProgressSync = (
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastProgressSyncTime.current;
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
if (timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
lastProgressSyncTime.current = now;
pushConfig(bookKey, config);
syncConfig();
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastProgressSyncTime.current = Date.now();
pushConfig(bookKey, config);
syncTimeoutRef.current = null;
syncConfig();
},
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
);
@@ -66,9 +80,9 @@ export const useProgressSync = (
}, [config]);
// sync progress once when the book is opened
const configSynced = useRef(false);
useEffect(() => {
if (!configSynced.current && syncedConfigs?.length > 0) {
if (!configSynced.current && syncedConfigs) {
configSynced.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const newConfig = deserializeConfig(
@@ -77,13 +91,15 @@ export const useProgressSync = (
DEFAULT_BOOK_SEARCH_CONFIG,
);
setConfig(bookKey, { ...config, ...newConfig });
configSynced.current = true;
if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config?.progress?.[1] ?? 0) > 0) {
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
const configFraction = config!.progress![0] / config!.progress![1];
if (syncedFraction > configFraction) {
view?.goToFraction(syncedFraction);
setToastMessage?.(_('Reading progress synced'));
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Reading progress synced'),
});
}
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
import { useEffect } from 'react';
import { useTheme } from '@/hooks/useTheme';
import { isTauriAppPlatform } from '@/services/environment';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import Reader from './components/Reader';
@@ -10,7 +10,7 @@ export default function Page() {
useTheme();
useEffect(() => {
const doAppUpdates = async () => {
if (isTauriAppPlatform()) {
if (hasUpdater()) {
await checkForAppUpdates();
}
};
@@ -3,10 +3,11 @@ import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { Book } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { formatDate, formatSubject } from '@/utils/book';
import { formatDate, formatLanguage, formatPublisher, formatSubject } from '@/utils/book';
import WindowButtons from '@/components/WindowButtons';
import Spinner from './Spinner';
@@ -19,19 +20,25 @@ interface BookDetailModalProps {
const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
const _ = useTranslation();
const [loading, setLoading] = useState(false);
const [bookMeta, setBookMeta] = useState<null | {
title: string;
language: string | string[];
editor?: string;
publisher?: string;
published?: string;
description?: string;
subject?: string[];
identifier?: string;
}>(null);
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
useEffect(() => {
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const fetchBookDetails = async () => {
@@ -98,10 +105,10 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
</div>
</div>
<div className='title-author flex h-40 flex-col justify-between pr-4'>
<div className='title-author flex h-40 max-w-[60%] flex-col justify-between pr-4'>
<div>
<h2 className='text-base-content mb-2 line-clamp-2 text-2xl font-bold'>
{bookMeta.title || _('Untitled')}
<h2 className='text-base-content mb-2 line-clamp-2 break-all text-2xl font-bold'>
{book.title || _('Untitled')}
</h2>
<p className='text-neutral-content line-clamp-1'>{book.author || _('Unknown')}</p>
</div>
@@ -116,7 +123,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
<div className='overflow-hidden'>
<span className='font-bold'>{_('Publisher:')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{bookMeta.publisher || _('Unknown')}
{formatPublisher(bookMeta.publisher || '') || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
@@ -134,7 +141,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
<div className='grid grid-cols-3 gap-4'>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Language:')}</span>
<p className='text-neutral-content text-sm'>{bookMeta.language || _('Unknown')}</p>
<p className='text-neutral-content text-sm'>
{formatLanguage(bookMeta.language) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Identifier:')}</span>
+2 -2
View File
@@ -34,7 +34,7 @@ const Dropdown: React.FC<DropdownProps> = ({
: children;
return (
<>
<div className='dropdown-container'>
{isOpen && (
<div className='fixed inset-0 bg-transparent' onClick={() => setIsDropdownOpen(false)} />
)}
@@ -48,7 +48,7 @@ const Dropdown: React.FC<DropdownProps> = ({
</div>
{isOpen && childrenWithToggle}
</div>
</>
</div>
);
};
+2 -2
View File
@@ -21,7 +21,7 @@ const Popup = ({
}) => (
<div>
<div
className={`triangle text-base-200 absolute z-10 ${triangleClassName}`}
className={`triangle text-base-200 absolute z-40 ${triangleClassName}`}
style={{
left:
trianglePosition?.dir === 'left'
@@ -67,7 +67,7 @@ const Popup = ({
/>
<div
id='popup-container'
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
className={`bg-base-200 absolute z-30 rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
height: `${height}px`,
+62 -13
View File
@@ -1,16 +1,65 @@
import { eventDispatcher } from '@/utils/event';
import clsx from 'clsx';
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({
message,
toastClass,
alertClass,
}) => (
<div className={clsx('toast toast-center toast-middle', toastClass)}>
<div className={clsx('alert flex items-center justify-center border-0', alertClass)}>
<span>{message}</span>
</div>
</div>
);
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export default Toast;
export const Toast = () => {
const [toastMessage, setToastMessage] = useState('');
const toastType = useRef<ToastType>('info');
const toastTimeout = useRef(5000);
const messageClass = useRef('');
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastClassMap = {
info: 'toast-info toast-center toast-middle',
success: 'toast-success toast-top toast-end pt-11',
warning: 'toast-warning toast-top toast-end pt-11',
error: 'toast-error toast-top toast-end pt-11',
};
const alertClassMap = {
info: 'alert-primary',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error',
};
useEffect(() => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
toastDismissTimeout.current = setTimeout(() => setToastMessage(''), toastTimeout.current);
return () => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
};
}, [toastMessage]);
const handleShowToast = async (event: CustomEvent) => {
const { message, type = 'info', timeout = 5000, className = '' } = event.detail;
setToastMessage(message);
toastType.current = type;
toastTimeout.current = timeout;
messageClass.current = className;
};
useEffect(() => {
eventDispatcher.on('toast', handleShowToast);
return () => {
eventDispatcher.off('toast', handleShowToast);
};
}, []);
return (
toastMessage && (
<div className={clsx('toast toast-center toast-middle', toastClassMap[toastType.current])}>
<div
className={clsx(
'alert flex max-w-80 items-center justify-center border-0',
alertClassMap[toastType.current],
)}
>
<span className={clsx('whitespace-normal break-words', messageClass.current)}>
{toastMessage}
</span>
</div>
</div>
)
);
};
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { tauriHandleMinimize, tauriHandleToggleMaximize, tauriHandleClose } from '@/utils/window';
import { isTauriAppPlatform } from '@/services/environment';
@@ -44,6 +45,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
onClose,
}) => {
const parentRef = useRef<HTMLDivElement>(null);
const { appService } = useEnv();
const handleMouseDown = async (e: MouseEvent) => {
const target = e.target as HTMLElement;
@@ -51,6 +53,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
if (
target.closest('.btn') ||
target.closest('.window-button') ||
target.closest('.dropdown-container') ||
target.closest('.exclude-title-bar-mousedown')
) {
return;
@@ -110,7 +113,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
className,
)}
>
{showMinimize && (
{showMinimize && appService?.hasWindowBar && (
<WindowButton onClick={handleMinimize} ariaLabel='Minimize' id='titlebar-minimize'>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path fill='currentColor' d='M20 14H4v-2h16' />
@@ -118,7 +121,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
</WindowButton>
)}
{showMaximize && (
{showMaximize && appService?.hasWindowBar && (
<WindowButton onClick={handleMaximize} ariaLabel='Maximize/Restore' id='titlebar-maximize'>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path fill='currentColor' d='M4 4h16v16H4zm2 4v10h12V8z' />
@@ -126,7 +129,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
</WindowButton>
)}
{showClose && (
{showClose && (appService?.hasWindowBar || onClose) && (
<WindowButton onClick={handleClose} ariaLabel='Close' id='titlebar-close'>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path
+36 -16
View File
@@ -29,27 +29,43 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
});
useEffect(() => {
const fetchSession = async () => {
const {
data: { session },
} = await supabase.auth.getSession();
const syncSession = (
session: { access_token: string; refresh_token: string; user: User } | null,
) => {
if (session) {
const { access_token, user } = session;
console.log('Syncing session');
const { access_token, refresh_token, user } = session;
localStorage.setItem('token', access_token);
localStorage.setItem('refresh_token', refresh_token);
localStorage.setItem('user', JSON.stringify(user));
setToken(access_token);
setUser(user);
localStorage.setItem('token', access_token);
localStorage.setItem('user', JSON.stringify(user));
} else {
console.log('Clearing session');
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}
};
const refreshSession = async () => {
try {
await supabase.auth.refreshSession();
} catch {
syncSession(null);
}
};
console.log('Fetching session');
fetchSession();
}, [token]);
const { data: subscription } = supabase.auth.onAuthStateChange((_, session) => {
syncSession(session);
});
refreshSession();
return () => {
subscription?.subscription.unsubscribe();
};
}, []);
const login = (newToken: string, newUser: User) => {
console.log('Logging in');
@@ -61,12 +77,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const logout = async () => {
console.log('Logging out');
await supabase.auth.refreshSession();
await supabase.auth.signOut();
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
try {
await supabase.auth.refreshSession();
} catch {
} finally {
await supabase.auth.signOut();
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}
};
return (
+2 -2
View File
@@ -1,4 +1,4 @@
import { isWebAppPlatform } from '@/services/environment';
import { isWebAppPlatform, hasCli } from '@/services/environment';
declare global {
interface Window {
@@ -36,7 +36,7 @@ export const parseOpenWithFiles = async () => {
if (isWebAppPlatform()) return [];
let files = parseWindowOpenWithFiles();
if (!files) {
if (!files && hasCli()) {
files = await parseCLIOpenWithFiles();
}
return files;
+1 -1
View File
@@ -28,7 +28,7 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onReloadPage: ['shift+r'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h'],
onGoRight: ['ArrowRight', 'PageDown', 'l'],
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
onGoNext: ['ArrowDown', 'j'],
onGoPrev: ['ArrowUp', 'k'],
onGoBack: ['shift+ArrowLeft', 'shift+h'],
+18 -15
View File
@@ -51,14 +51,15 @@ export function useSync(bookKey?: string) {
);
const [syncing, setSyncing] = useState(false);
// null means unsynced, empty array means synced no changes
const [syncResult, setSyncResult] = useState<SyncResult>({
books: [],
configs: [],
notes: [],
books: null,
configs: null,
notes: null,
});
const [syncedBooks, setSyncedBooks] = useState<Book[]>([]);
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[]>([]);
const [syncedNotes, setSyncedNotes] = useState<BookNote[]>([]);
const [syncedBooks, setSyncedBooks] = useState<Book[] | null>(null);
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[] | null>(null);
const [syncedNotes, setSyncedNotes] = useState<BookNote[] | null>(null);
const { syncClient } = useSyncContext();
@@ -76,11 +77,11 @@ export function useSync(bookKey?: string) {
try {
const result = await syncClient.pullChanges(since, type, bookId);
setSyncResult({ ...syncResult, [type]: result[type] });
const records = result[type];
if (!records.length) return;
const maxTime = computeMaxTimestamp(result[type]);
if (!records?.length) return;
const maxTime = computeMaxTimestamp(records);
setLastSyncedAt(maxTime);
setSyncResult(result);
switch (type) {
case 'books':
settings.lastSyncedAtBooks = maxTime;
@@ -169,16 +170,18 @@ export function useSync(bookKey?: string) {
useEffect(() => {
if (!syncing && syncResult) {
const { books: dbBooks, configs: dbBookConfigs, notes: dbBookNotes } = syncResult;
const books = dbBooks.map((dbBook) => transformsFromDB['books'](dbBook as unknown as DBBook));
const configs = dbBookConfigs.map((dbBookConfig) =>
const books = dbBooks?.map((dbBook) =>
transformsFromDB['books'](dbBook as unknown as DBBook),
);
const configs = dbBookConfigs?.map((dbBookConfig) =>
transformsFromDB['configs'](dbBookConfig as unknown as DBBookConfig),
);
const notes = dbBookNotes.map((dbBookNote) =>
const notes = dbBookNotes?.map((dbBookNote) =>
transformsFromDB['notes'](dbBookNote as unknown as DBBookNote),
);
if (books.length) setSyncedBooks(books);
if (configs.length) setSyncedConfigs(configs);
if (notes.length) setSyncedNotes(notes);
if (books) setSyncedBooks(books);
if (configs) setSyncedConfigs(configs);
if (notes) setSyncedNotes(notes);
}
}, [syncResult, syncing]);
+8 -2
View File
@@ -1,4 +1,5 @@
import { BookFormat } from '@/types/book';
import { Contributor, LanguageMap } from '@/utils/book';
import * as epubcfi from 'foliate-js/epubcfi.js';
// A groupBy polyfill for foliate-js
@@ -51,11 +52,16 @@ export interface SectionItem {
export interface BookDoc {
metadata: {
title: string;
author: string;
// NOTE: the title and author fields should be formatted
title: string | LanguageMap;
author: string | Contributor;
language: string | string[];
editor?: string;
publisher?: string;
published?: string;
description?: string;
subject?: string[];
identifier?: string;
};
toc?: Array<TOCItem>;
sections?: Array<SectionItem>;
+285
View File
@@ -0,0 +1,285 @@
import { md5 } from 'js-md5';
import { randomMd5 } from '@/utils/misc';
import { LRUCache } from '@/utils/lru';
const EDGE_SPEECH_URL =
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
const EDGE_API_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
const EDGE_TTS_VOICES = {
'af-ZA': ['af-ZA-AdriNeural', 'af-ZA-WillemNeural'],
'am-ET': ['am-ET-AmehaNeural', 'am-ET-MekdesNeural'],
'ar-AE': ['ar-AE-FatimaNeural', 'ar-AE-HamdanNeural'],
'ar-BH': ['ar-BH-AliNeural', 'ar-BH-LailaNeural'],
'ar-DZ': ['ar-DZ-AminaNeural', 'ar-DZ-IsmaelNeural'],
'ar-EG': ['ar-EG-SalmaNeural', 'ar-EG-ShakirNeural'],
'ar-IQ': ['ar-IQ-BasselNeural', 'ar-IQ-RanaNeural'],
'ar-JO': ['ar-JO-SanaNeural', 'ar-JO-TaimNeural'],
'ar-KW': ['ar-KW-FahedNeural', 'ar-KW-NouraNeural'],
'ar-LB': ['ar-LB-LaylaNeural', 'ar-LB-RamiNeural'],
'ar-LY': ['ar-LY-ImanNeural', 'ar-LY-OmarNeural'],
'ar-MA': ['ar-MA-JamalNeural', 'ar-MA-MounaNeural'],
'ar-OM': ['ar-OM-AbdullahNeural', 'ar-OM-AyshaNeural'],
'ar-QA': ['ar-QA-AmalNeural', 'ar-QA-MoazNeural'],
'ar-SA': ['ar-SA-HamedNeural', 'ar-SA-ZariyahNeural'],
'ar-SY': ['ar-SY-AmanyNeural', 'ar-SY-LaithNeural'],
'ar-TN': ['ar-TN-HediNeural', 'ar-TN-ReemNeural'],
'ar-YE': ['ar-YE-MaryamNeural', 'ar-YE-SalehNeural'],
'az-AZ': ['az-AZ-BabekNeural', 'az-AZ-BanuNeural'],
'bg-BG': ['bg-BG-BorislavNeural', 'bg-BG-KalinaNeural'],
'bn-BD': ['bn-BD-NabanitaNeural', 'bn-BD-PradeepNeural'],
'bn-IN': ['bn-IN-BashkarNeural', 'bn-IN-TanishaaNeural'],
'bs-BA': ['bs-BA-GoranNeural', 'bs-BA-VesnaNeural'],
'ca-ES': ['ca-ES-EnricNeural', 'ca-ES-JoanaNeural'],
'cs-CZ': ['cs-CZ-AntoninNeural', 'cs-CZ-VlastaNeural'],
'cy-GB': ['cy-GB-AledNeural', 'cy-GB-NiaNeural'],
'da-DK': ['da-DK-ChristelNeural', 'da-DK-JeppeNeural'],
'de-AT': ['de-AT-IngridNeural', 'de-AT-JonasNeural'],
'de-CH': ['de-CH-JanNeural', 'de-CH-LeniNeural'],
'de-DE': [
'de-DE-AmalaNeural',
'de-DE-ConradNeural',
'de-DE-FlorianMultilingualNeural',
'de-DE-KatjaNeural',
'de-DE-KillianNeural',
'de-DE-SeraphinaMultilingualNeural',
],
'el-GR': ['el-GR-AthinaNeural', 'el-GR-NestorasNeural'],
'en-AU': ['en-AU-NatashaNeural', 'en-AU-WilliamNeural'],
'en-CA': ['en-CA-ClaraNeural', 'en-CA-LiamNeural'],
'en-GB': [
'en-GB-LibbyNeural',
'en-GB-MaisieNeural',
'en-GB-RyanNeural',
'en-GB-SoniaNeural',
'en-GB-ThomasNeural',
],
'en-HK': ['en-HK-SamNeural', 'en-HK-YanNeural'],
'en-IE': ['en-IE-ConnorNeural', 'en-IE-EmilyNeural'],
'en-IN': ['en-IN-NeerjaExpressiveNeural', 'en-IN-NeerjaNeural', 'en-IN-PrabhatNeural'],
'en-KE': ['en-KE-AsiliaNeural', 'en-KE-ChilembaNeural'],
'en-NG': ['en-NG-AbeoNeural', 'en-NG-EzinneNeural'],
'en-NZ': ['en-NZ-MitchellNeural', 'en-NZ-MollyNeural'],
'en-PH': ['en-PH-JamesNeural', 'en-PH-RosaNeural'],
'en-SG': ['en-SG-LunaNeural', 'en-SG-WayneNeural'],
'en-TZ': ['en-TZ-ElimuNeural', 'en-TZ-ImaniNeural'],
'en-US': [
'en-US-AnaNeural',
'en-US-AndrewMultilingualNeural',
'en-US-AndrewNeural',
'en-US-AriaNeural',
'en-US-AvaMultilingualNeural',
'en-US-AvaNeural',
'en-US-BrianMultilingualNeural',
'en-US-BrianNeural',
'en-US-ChristopherNeural',
'en-US-EmmaMultilingualNeural',
'en-US-EmmaNeural',
'en-US-EricNeural',
'en-US-GuyNeural',
'en-US-JennyNeural',
'en-US-MichelleNeural',
'en-US-RogerNeural',
'en-US-SteffanNeural',
],
'es-AR': ['es-AR-ElenaNeural', 'es-AR-TomasNeural'],
'es-BO': ['es-BO-MarceloNeural', 'es-BO-SofiaNeural'],
'es-CL': ['es-CL-CatalinaNeural', 'es-CL-LorenzoNeural'],
'es-CO': ['es-CO-GonzaloNeural', 'es-CO-SalomeNeural'],
'es-CR': ['es-CR-JuanNeural', 'es-CR-MariaNeural'],
'es-CU': ['es-CU-BelkysNeural', 'es-CU-ManuelNeural'],
'es-DO': ['es-DO-EmilioNeural', 'es-DO-RamonaNeural'],
'es-EC': ['es-EC-AndreaNeural', 'es-EC-LuisNeural'],
'es-ES': ['es-ES-AlvaroNeural', 'es-ES-ElviraNeural', 'es-ES-XimenaNeural'],
'es-US': ['es-US-AlonsoNeural', 'es-US-PalomaNeural'],
'fr-BE': ['fr-BE-CharlineNeural', 'fr-BE-GerardNeural'],
'fr-CA': ['fr-CA-AntoineNeural', 'fr-CA-JeanNeural', 'fr-CA-SylvieNeural', 'fr-CA-ThierryNeural'],
'fr-CH': ['fr-CH-ArianeNeural', 'fr-CH-FabriceNeural'],
'fr-FR': [
'fr-FR-DeniseNeural',
'fr-FR-EloiseNeural',
'fr-FR-HenriNeural',
'fr-FR-RemyMultilingualNeural',
'fr-FR-VivienneMultilingualNeural',
],
'ja-JP': ['ja-JP-KeitaNeural', 'ja-JP-NanamiNeural'],
'ko-KR': ['ko-KR-HyunsuMultilingualNeural', 'ko-KR-InJoonNeural', 'ko-KR-SunHiNeural'],
'pt-BR': ['pt-BR-AntonioNeural', 'pt-BR-FranciscaNeural', 'pt-BR-ThalitaMultilingualNeural'],
'pt-PT': ['pt-PT-DuarteNeural', 'pt-PT-RaquelNeural'],
'zh-CN': [
'zh-CN-XiaoxiaoNeural',
'zh-CN-XiaoyiNeural',
'zh-CN-YunjianNeural',
'zh-CN-YunxiNeural',
'zh-CN-YunxiaNeural',
'zh-CN-YunyangNeural',
'zh-CN-liaoning-XiaobeiNeural',
'zh-CN-shaanxi-XiaoniNeural',
],
'zh-HK': ['zh-HK-HiuGaaiNeural', 'zh-HK-HiuMaanNeural', 'zh-HK-WanLungNeural'],
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural'],
};
const genVoiceList = (voices: Record<string, string[]>) => {
return Object.entries(voices).flatMap(([lang, voices]) => {
return voices.map((id) => {
const name = id.replace(`${lang}-`, '').replace('Neural', '');
return { name, id, lang };
});
});
};
export interface EdgeTTSPayload {
lang: string;
text: string;
voice: string;
rate: number;
pitch: number;
}
const hashPayload = (payload: EdgeTTSPayload): string => {
const base = JSON.stringify(payload);
return md5(base);
};
export class EdgeSpeechTTS {
static voices = genVoiceList(EDGE_TTS_VOICES);
private static audioCache = new LRUCache<string, AudioBuffer>(200);
constructor() {}
async #fetchEdgeSpeechWs({ lang, text, voice, rate }: EdgeTTSPayload): Promise<Response> {
const connectId = randomMd5();
const url = `${EDGE_SPEECH_URL}?ConnectionId=${connectId}&TrustedClientToken=${EDGE_API_TOKEN}`;
const date = new Date().toString();
const configHeaders = {
'Content-Type': 'application/json; charset=utf-8',
Path: 'speech.config',
'X-Timestamp': date,
};
const contentHeaders = {
'Content-Type': 'application/ssml+xml',
Path: 'ssml',
'X-RequestId': connectId,
'X-Timestamp': date,
};
const configContent = JSON.stringify({
context: {
synthesis: {
audio: {
metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: true },
outputFormat: 'audio-24khz-48kbitrate-mono-mp3',
},
},
},
});
const genSSML = (lang: string, text: string, voice: string, rate: number) => {
return `
<speak version="1.0" xml:lang="${lang}">
<voice name="${voice}">
<prosody rate="${rate}">
${text}
</prosody>
</voice>
</speak>
`;
};
const genSendContent = (headerObj: Record<string, string>, content: string) => {
let header = '';
for (const key of Object.keys(headerObj)) {
header += `${key}: ${headerObj[key]}\r\n`;
}
return `${header}\r\n${content}`;
};
const getHeadersAndData = (message: string) => {
const lines = message.split('\n');
const headers: Record<string, string> = {};
let body = '';
let lineIdx = 0;
for (lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx]!.trim();
if (!line) break;
const separatorIndex = line.indexOf(':');
if (separatorIndex === -1) continue;
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim();
headers[key] = value;
}
for (lineIdx = lineIdx + 1; lineIdx < lines.length; lineIdx++) {
body += lines[lineIdx] + '\n';
}
return { headers, body };
};
const ssml = genSSML(lang, text, voice, rate);
const content = genSendContent(contentHeaders, ssml);
const config = genSendContent(configHeaders, configContent);
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
let audioData = new ArrayBuffer(0);
ws.addEventListener('open', () => {
ws.send(config);
ws.send(content);
});
ws.addEventListener('message', (event: MessageEvent) => {
if (typeof event.data === 'string') {
const { headers } = getHeadersAndData(event.data);
if (headers['Path'] === 'turn.end') {
ws.close();
if (!audioData.byteLength) {
return reject(new Error('No audio data received.'));
}
const res = new Response(audioData);
resolve(res);
}
} else if (event.data instanceof ArrayBuffer) {
const dataView = new DataView(event.data);
const headerLength = dataView.getInt16(0);
if (event.data.byteLength > headerLength + 2) {
const newBody = event.data.slice(2 + headerLength);
const merged = new Uint8Array(audioData.byteLength + newBody.byteLength);
merged.set(new Uint8Array(audioData), 0);
merged.set(new Uint8Array(newBody), audioData.byteLength);
audioData = merged.buffer;
}
}
});
ws.addEventListener('error', () => {
ws.close();
reject(new Error('WebSocket error occurred.'));
});
});
}
async create(payload: EdgeTTSPayload): Promise<Response> {
return this.#fetchEdgeSpeechWs(payload);
}
async createAudio(payload: EdgeTTSPayload): Promise<AudioBuffer> {
const cacheKey = hashPayload(payload);
if (EdgeSpeechTTS.audioCache.has(cacheKey)) {
return EdgeSpeechTTS.audioCache.get(cacheKey)!;
}
try {
const res = await this.create(payload);
const arrayBuffer = await res.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
EdgeSpeechTTS.audioCache.set(cacheKey, audioBuffer);
return audioBuffer;
} catch (error) {
throw error;
}
}
}
+9 -3
View File
@@ -18,9 +18,9 @@ interface BookConfigRecord extends BookDataRecord, BookConfig {}
interface BookNoteRecord extends BookDataRecord, BookNote {}
export interface SyncResult {
books: BookRecord[];
notes: BookNoteRecord[];
configs: BookConfigRecord[];
books: BookRecord[] | null;
notes: BookNoteRecord[] | null;
configs: BookConfigRecord[] | null;
}
export interface SyncData {
@@ -79,6 +79,12 @@ export class SyncClient {
}
private async getAccessToken(): Promise<string | null> {
// In browser context there might be two instances of supabase one in the app route
// and the other in the pages route, and they might have different sessions
// making the access token invalid for API calls. In that case we should use localStorage.
if (isWebAppPlatform()) {
return localStorage.getItem('token') ?? null;
}
const { data } = await supabase.auth.getSession();
return data?.session?.access_token ?? null;
}
@@ -25,6 +25,7 @@ import {
DEFAULT_READSETTINGS,
SYSTEM_SETTINGS_VERSION,
DEFAULT_BOOK_SEARCH_CONFIG,
DEFAULT_TTS_CONFIG,
} from './constants';
import { isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
@@ -35,6 +36,7 @@ export abstract class BaseAppService implements AppService {
abstract appPlatform: AppPlatform;
abstract isAppDataSandbox: boolean;
abstract hasTrafficLight: boolean;
abstract hasWindowBar: boolean;
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
abstract getCoverImageUrl(book: Book): string;
@@ -67,6 +69,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...settings.globalViewSettings,
};
} catch {
@@ -83,6 +86,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
},
};
+201 -2
View File
@@ -1,4 +1,11 @@
import { BookFont, BookLayout, BookSearchConfig, BookStyle, ViewConfig } from '@/types/book';
import {
BookFont,
BookLayout,
BookSearchConfig,
BookStyle,
TTSConfig,
ViewConfig,
} from '@/types/book';
import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
@@ -8,7 +15,7 @@ export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz',
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '25%',
sideBarWidth: '15%',
isSideBarPinned: true,
notebookWidth: '25%',
isNotebookPinned: false,
@@ -42,6 +49,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
maxInlineSize: 720,
maxBlockSize: 1440,
animated: false,
writingMode: 'auto',
vertical: false,
};
@@ -60,6 +68,11 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsRate: 1.0,
ttsVoice: '',
};
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
scope: 'book',
matchCase: false,
@@ -82,6 +95,192 @@ export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
export const FALLBACK_FONTS = ['MiSans L3'];
export const WINDOWS_FONTS = [
'Arial',
'Arial Black',
'Bahnschrift',
'Calibri',
'Cambria',
'Cambria Math',
'Candara',
'Comic Sans MS',
'Consolas',
'Constantia',
'Corbel',
'Courier New',
'Ebrima',
'FangSong',
'Franklin Gothic Medium',
'Gabriola',
'Gadugi',
'Georgia',
'HoloLens MDL2 Assets',
'Impact',
'Ink Free',
'Javanese Text',
'KaiTi',
'Leelawadee UI',
'Lucida Console',
'Lucida Sans Unicode',
'Malgun Gothic',
'Marlett',
'Microsoft Himalaya',
'Microsoft JhengHei',
'Microsoft New Tai Lue',
'Microsoft PhagsPa',
'Microsoft Sans Serif',
'Microsoft Tai Le',
'Microsoft YaHei',
'Microsoft Yi Baiti',
'MingLiU',
'MingLiU-ExtB',
'Mongolian Baiti',
'MS Gothic',
'MV Boli',
'Myanmar Text',
'Nirmala UI',
'NSimSun',
'Palatino Linotype',
'PMingLiU',
'Segoe MDL2 Assets',
'Segoe Print',
'Segoe Script',
'Segoe UI',
'Segoe UI Historic',
'Segoe UI Emoji',
'Segoe UI Symbol',
'SimHei',
'SimSun',
'Sitka',
'Sylfaen',
'Symbol',
'Tahoma',
'Times New Roman',
'Trebuchet MS',
'Verdana',
'Webdings',
'Wingdings',
'Yu Gothic',
];
export const MACOS_FONTS = [
'American Typewriter',
'Andale Mono',
'Arial',
'Arial Black',
'Arial Narrow',
'Arial Rounded MT Bold',
'Arial Unicode MS',
'Avenir',
'Avenir Next',
'Avenir Next Condensed',
'Baskerville',
'BiauKai',
'Big Caslon',
'Bodoni 72',
'Bodoni 72 Oldstyle',
'Bodoni 72 Smallcaps',
'Bradley Hand',
'Brush Script MT',
'Chalkboard',
'Chalkboard SE',
'Chalkduster',
'Charter',
'Cochin',
'Comic Sans MS',
'Copperplate',
'Courier',
'Courier New',
'Didot',
'DIN Alternate',
'DIN Condensed',
'Futura',
'Geneva',
'Georgia',
'Gill Sans',
'Heiti SC',
'Heiti TC',
'Helvetica',
'Helvetica Neue',
'Herculanum',
'Hoefler Text',
'Impact',
'Kaiti SC',
'Kaiti TC',
'Lucida Grande',
'Luminari',
'Marker Felt',
'Menlo',
'Microsoft Sans Serif',
'Monaco',
'Noteworthy',
'Optima',
'Palatino',
'Papyrus',
'PingFang HK',
'PingFang SC',
'PingFang TC',
'Phosphate',
'Rockwell',
'Savoye LET',
'SignPainter',
'Skia',
'Snell Roundhand',
'Songti SC',
'Songti TC',
'STFangsong',
'STKaiti',
'STSong',
'STXihei',
'Tahoma',
'Times',
'Times New Roman',
'Trattatello',
'Trebuchet MS',
'Verdana',
'Zapfino',
];
export const LINUX_FONTS = [
'Arial',
'Cantarell',
'Comic Sans MS',
'Courier New',
'DejaVu Sans',
'DejaVu Sans Mono',
'DejaVu Serif',
'Droid Sans',
'Droid Sans Mono',
'FreeMono',
'FreeSans',
'FreeSerif',
'Georgia',
'Impact',
'Liberation Mono',
'Liberation Sans',
'Liberation Serif',
'Noto Mono',
'Noto Sans',
'Noto Sans CJK SC',
'Noto Sans CJK TC',
'Noto Serif',
'Noto Serif CJK SC',
'Noto Serif CJK TC',
'Open Sans',
'Poppins',
'Source Han Sans',
'Source Han Serif',
'Symbola',
'Times New Roman',
'Ubuntu',
'Ubuntu Mono',
'Wingdings',
'WenQuanYi Micro Hei',
'WenQuanYi Zen Hei',
];
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
export const BOOK_IDS_SEPARATOR = '+';
@@ -1,7 +1,16 @@
import { AppService } from '@/types/system';
declare global {
interface Window {
__READEST_CLI_ACCESS?: boolean;
__READEST_UPDATER_ACCESS?: boolean;
}
}
export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri';
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
export const hasUpdater = () => window.__READEST_UPDATER_ACCESS === true;
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
export interface EnvConfigType {
getAppService: () => Promise<AppService>;
@@ -119,6 +119,7 @@ export class NativeAppService extends BaseAppService {
appPlatform = 'tauri' as AppPlatform;
isAppDataSandbox = isMobile;
hasTrafficLight = osType() === 'macos';
hasWindowBar = !(osType() === 'ios' || osType() === 'android');
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
@@ -0,0 +1,223 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
export class EdgeTTSClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: TTSVoice | null = null;
#voices: TTSVoice[] = [];
#edgeTTS: EdgeSpeechTTS;
#audioContext: AudioContext | null = null;
#sourceNode: AudioBufferSourceNode | null = null;
#isPlaying = false;
#pausedAt = 0;
#startedAt = 0;
#audioBuffer: AudioBuffer | null = null;
available = true;
constructor() {
this.#edgeTTS = new EdgeSpeechTTS();
}
async init() {
this.#voices = EdgeSpeechTTS.voices;
try {
await this.#edgeTTS.create({
lang: 'en',
text: 'test',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
this.available = true;
} catch {
this.available = false;
}
return this.available;
}
getPayload = (lang: string, text: string, voiceId: string) => {
return { lang, text, voice: voiceId, rate: this.#rate, pitch: this.#pitch } as EdgeTTSPayload;
};
async *speak(
ssml: string,
signal: AbortSignal,
preload = false,
): AsyncGenerator<TTSMessageEvent> {
const { marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml) || 'en';
let voiceId = 'en-US-AriaNeural';
if (!this.#voice) {
const voices = await this.getVoices(lang);
this.#voice = voices[0] ? voices[0] : this.#voices.find((v) => v.id === voiceId) || null;
}
if (this.#voice) {
voiceId = this.#voice.id;
}
if (preload) {
for (const mark of marks) {
await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((err) => {
console.warn('Error preloading:', err);
});
}
yield {
code: 'end',
message: 'Preload finished',
};
return;
} else {
await this.stopInternal();
}
for (const mark of marks) {
if (signal.aborted) {
yield {
code: 'error',
message: 'Aborted',
};
break;
}
try {
this.#audioBuffer = await this.#edgeTTS.createAudio(
this.getPayload(lang, mark.text, voiceId),
);
this.#audioContext = new AudioContext();
this.#sourceNode = this.#audioContext.createBufferSource();
this.#sourceNode.buffer = this.#audioBuffer;
this.#sourceNode.connect(this.#audioContext.destination);
yield {
code: 'boundary',
message: `Start chunk: ${mark.name}`,
mark: mark.name,
};
const result = await new Promise<TTSMessageEvent>((resolve) => {
if (this.#audioContext === null || this.#sourceNode === null) {
throw new Error('Audio context or source node is null');
}
this.#sourceNode.onended = () => {
this.#isPlaying = false;
resolve({
code: 'end',
message: `Chunk finished: ${mark.name}`,
});
};
if (signal.aborted) {
resolve({
code: 'error',
message: 'Aborted',
});
return;
}
this.#sourceNode.start(0);
this.#isPlaying = true;
this.#startedAt = this.#audioContext.currentTime;
});
yield result;
} catch (error) {
if (error instanceof Error && error.message === 'No audio data received.') {
console.warn('No audio data received for:', mark.text);
yield {
code: 'end',
message: `Chunk finished: ${mark.name}`,
};
continue;
}
console.log('Error:', error);
yield {
code: 'error',
message: error instanceof Error ? error.message : String(error),
};
break;
}
await this.stopInternal();
}
}
async pause() {
if (!this.#isPlaying || !this.#audioContext || !this.#sourceNode) return;
this.#pausedAt = this.#audioContext.currentTime - this.#startedAt;
await this.#audioContext.suspend();
this.#isPlaying = false;
}
async resume() {
if (this.#isPlaying || !this.#audioContext || !this.#sourceNode) return;
await this.#audioContext.resume();
this.#isPlaying = true;
this.#startedAt = this.#audioContext.currentTime - this.#pausedAt;
}
async stop() {
await this.stopInternal();
}
private async stopInternal() {
this.#isPlaying = false;
this.#pausedAt = 0;
this.#startedAt = 0;
if (this.#sourceNode) {
try {
this.#sourceNode.stop();
} catch (err) {
if (!(err instanceof Error) || err.name !== 'InvalidStateError') {
console.log('Error stopping source node:', err);
}
}
this.#sourceNode.disconnect();
this.#sourceNode = null;
}
if (this.#audioContext) {
await this.#audioContext.close();
this.#audioContext = null;
}
this.#audioBuffer = null;
}
async setRate(rate: number) {
// The Edge TTS API uses rate in [0.5 .. 2.0].
this.#rate = rate;
}
async setPitch(pitch: number) {
// The Edge TTS API uses pitch in [0.5 .. 1.5].
this.#pitch = pitch;
}
async setVoice(voice: string) {
const selectedVoice = this.#voices.find((v) => v.id === voice);
if (selectedVoice) {
this.#voice = selectedVoice;
}
}
async getAllVoices(): Promise<TTSVoice[]> {
this.#voices.forEach((voice) => {
voice.disabled = !this.available;
});
return this.#voices;
}
async getVoices(lang: string): Promise<TTSVoice[]> {
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
const voices = await this.getAllVoices();
return voices.filter((v) => v.lang.startsWith(locale));
}
getGranularities(): TTSGranularity[] {
return ['sentence'];
}
getVoiceId(): string {
return this.#voice?.id || '';
}
}
@@ -0,0 +1,31 @@
import { TTSGranularity } from '@/types/view';
export type TTSMessageCode = 'boundary' | 'error' | 'end';
export interface TTSMessageEvent {
code: TTSMessageCode;
message?: string;
mark?: string;
}
export interface TTSVoice {
id: string;
name: string;
lang: string;
disabled?: boolean;
}
export interface TTSClient {
init(): Promise<boolean>;
speak(ssml: string, signal: AbortSignal, preload?: boolean): AsyncIterable<TTSMessageEvent>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
setRate(rate: number): Promise<void>;
setPitch(pitch: number): Promise<void>;
setVoice(voice: string): Promise<void>;
getAllVoices(): Promise<TTSVoice[]>;
getVoices(lang: string): Promise<TTSVoice[]>;
getGranularities(): TTSGranularity[];
getVoiceId(): string;
}
@@ -0,0 +1,221 @@
import { FoliateView, TTSGranularity } from '@/types/view';
import { TTSClient, TTSMessageCode, TTSVoice } from './TTSClient';
import { WebSpeechClient } from './WebSpeechClient';
import { EdgeTTSClient } from './EdgeTTSClient';
type TTSState =
| 'stopped'
| 'playing'
| 'paused'
| 'backward-paused'
| 'forward-paused'
| 'setrate-paused'
| 'setvoice-paused';
export class TTSController extends EventTarget {
state: TTSState = 'stopped';
view: FoliateView;
#nossmlCnt: number = 0;
#currentSpeakAbortController: AbortController | null = null;
ttsRate: number = 1.0;
ttsClient: TTSClient;
ttsWebClient: TTSClient;
ttsEdgeClient: TTSClient;
ttsWebVoices: TTSVoice[] = [];
ttsEdgeVoices: TTSVoice[] = [];
constructor(view: FoliateView) {
super();
this.ttsWebClient = new WebSpeechClient();
this.ttsEdgeClient = new EdgeTTSClient();
this.ttsClient = this.ttsWebClient;
this.view = view;
}
async init() {
await this.ttsWebClient.init();
const success = await this.ttsEdgeClient.init();
if (success) {
this.ttsClient = this.ttsEdgeClient;
} else {
this.ttsClient = this.ttsWebClient;
}
this.ttsWebVoices = await this.ttsWebClient.getAllVoices();
this.ttsEdgeVoices = await this.ttsEdgeClient.getAllVoices();
}
async initViewTTS() {
let granularity: TTSGranularity = this.view.language.isCJK ? 'sentence' : 'word';
const supportedGranularities = this.ttsClient.getGranularities();
if (!supportedGranularities.includes(granularity)) {
granularity = supportedGranularities[0]!;
}
await this.view.initTTS(granularity);
}
async preloadSSML(ssml: string | undefined) {
if (!ssml) return;
const iter = await this.ttsClient.speak(ssml, new AbortController().signal, true);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of iter);
}
async preloadNextSSML(count: number) {
const tts = this.view.tts;
if (!tts) return;
let preloaded = 0;
for (let i = 0; i < count; i++) {
const ssml = tts.next();
this.preloadSSML(ssml);
if (ssml) preloaded++;
}
for (let i = 0; i < preloaded; i++) {
tts.prev();
}
}
async #speak(ssml: string | undefined | Promise<string>) {
await this.stop();
this.#currentSpeakAbortController = new AbortController();
const { signal } = this.#currentSpeakAbortController;
try {
console.log('TTS speak');
this.state = 'playing';
ssml = await ssml;
await this.preloadSSML(ssml);
if (!ssml) {
this.#nossmlCnt++;
// FIXME: in case we are at the end of the book, need a better way to handle this
if (this.#nossmlCnt < 10 && this.state === 'playing') {
await this.view.next(1);
await this.forward();
}
return;
} else {
this.#nossmlCnt = 0;
}
const iter = await this.ttsClient.speak(ssml, signal);
let lastCode: TTSMessageCode = 'boundary';
for await (const { code, mark } of iter) {
if (mark && this.state === 'playing') {
this.view.tts?.setMark(mark);
}
lastCode = code;
}
if (lastCode === 'end' && this.state === 'playing') {
await this.forward();
}
} finally {
this.#currentSpeakAbortController = null;
}
}
async speak(ssml: string | Promise<string>) {
await this.initViewTTS();
this.#speak(ssml).catch((e) => this.error(e));
this.preloadNextSSML(2);
}
play() {
if (this.state !== 'playing') {
this.start();
} else {
this.pause();
}
}
async start() {
await this.initViewTTS();
const ssml = this.state.includes('paused') ? this.view.tts?.resume() : this.view.tts?.start();
this.#speak(ssml);
this.preloadNextSSML(2);
}
async pause() {
this.state = 'paused';
await this.ttsClient.pause().catch((e) => this.error(e));
}
async resume() {
this.state = 'playing';
await this.ttsClient.resume().catch((e) => this.error(e));
}
async stop() {
this.state = 'stopped';
if (this.#currentSpeakAbortController) {
this.#currentSpeakAbortController.abort();
}
await this.ttsClient.stop().catch((e) => this.error(e));
}
// goto previous sentence
async backward() {
await this.initViewTTS();
if (this.state === 'playing') {
await this.stop();
this.#speak(this.view.tts?.prev());
} else {
this.state = 'backward-paused';
this.view.tts?.prev(true);
}
}
// goto next sentence
async forward() {
await this.initViewTTS();
if (this.state === 'playing') {
await this.stop();
this.#speak(this.view.tts?.next());
this.preloadNextSSML(2);
} else {
this.state = 'forward-paused';
this.view.tts?.next(true);
}
}
async setRate(rate: number) {
this.state = 'setrate-paused';
this.ttsRate = rate;
await this.ttsClient.setRate(this.ttsRate);
}
async getVoices(lang: string) {
const ttsWebVoices = await this.ttsWebClient.getVoices(lang);
const ttsEdgeVoices = await this.ttsEdgeClient.getVoices(lang);
return [...ttsEdgeVoices, ...ttsWebVoices];
}
async setVoice(voiceId: string) {
this.state = 'setvoice-paused';
const useEdgeTTS = !!this.ttsEdgeVoices.find(
(voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled,
);
if (useEdgeTTS) {
this.ttsClient = this.ttsEdgeClient;
await this.ttsClient.setRate(this.ttsRate);
} else {
this.ttsClient = this.ttsWebClient;
await this.ttsClient.setRate(this.ttsRate);
}
await this.ttsClient.setVoice(voiceId);
}
getVoiceId() {
return this.ttsClient.getVoiceId();
}
error(e: unknown) {
console.error(e);
this.state = 'stopped';
}
async kill() {
this.state = 'stopped';
await this.ttsClient.stop();
}
}

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