Compare commits

..

1 Commits

Author SHA1 Message Date
chrox 90fee08be2 Release version 0.8.8 2024-12-29 12:48:55 +01:00
118 changed files with 570 additions and 3680 deletions
+3 -9
View File
@@ -1,7 +1,7 @@
---
name: Report a bug
about: Report a bug or a functional regression
title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling'
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
labels: ['type: bug']
assignees: ''
---
@@ -30,15 +30,9 @@ The blank square should be transparent (invisible)
## Technical inputs
Operating System:
Readest Version:
**Example:**
```
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
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
- Probably fixable with CSS
```
-13
View File
@@ -1,13 +0,0 @@
# 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
@@ -1,35 +0,0 @@
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
+12 -50
View File
@@ -10,21 +10,16 @@ jobs:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
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@v4
- uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v3
- 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@v7
uses: actions/github-script@v6
with:
script: |
const { data } = await github.rest.repos.getLatestRelease({
@@ -32,10 +27,9 @@ jobs:
repo: context.repo.repo,
})
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@v7
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
@@ -45,7 +39,6 @@ jobs:
const notes = releaseNotes.notes || [];
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:
@@ -75,7 +68,7 @@ jobs:
runs-on: ${{ matrix.config.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: initialize git submodules
run: git submodule update --init --recursive
@@ -86,7 +79,7 @@ jobs:
version: 9.14.4
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: 22
cache: pnpm
@@ -112,8 +105,6 @@ 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
@@ -138,38 +129,10 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
projectPath: apps/readest-app
releaseId: ${{ needs.get-release.outputs.release_id }}
releaseBody: ${{ needs.get-release.outputs.release_note }}
releaseId: ${{ needs.get-release.steps.get-release.outputs.release_id }}
releaseBody: ${{ needs.get-release.steps.get-release-notes.outputs.release_note }}
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:
contents: write
@@ -179,18 +142,17 @@ jobs:
steps:
- name: update release
id: update-release
uses: actions/github-script@v7
uses: actions/github-script@v6
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_note: ${{ needs.get-release.outputs.release_note }}
release_id: ${{ needs.get-release.steps.get-release.outputs.release_id }}
release_note: ${{ needs.get-release.steps.get-release-notes.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: body,
body: process.env.release_note,
draft: false,
prerelease: false
})
+25
View File
@@ -0,0 +1,25 @@
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/readest/readest.git
git clone https://github.com/chrox/readest.git
cd readest
git submodule update --init --recursive
```
+24 -37
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/readest/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
<img src="https://github.com/chrox/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. | 🛠 |
| **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. | 🔄 |
| **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. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -80,8 +80,6 @@ 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)
@@ -120,7 +118,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/readest/readest.git
git clone https://github.com/chrox/readest.git
cd readest
git submodule update --init --recursive
```
@@ -152,29 +150,19 @@ 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/readest/readest/graphs/contributors">
<a href="https://github.com/chrox/readest/graphs/contributors">
<p align="left">
<img width="300" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
<img width="100" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
@@ -195,19 +183,18 @@ 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/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[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-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/readest/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[badge-last-commit]: https://img.shields.io/github/last-commit/chrox/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/chrox/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/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-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-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: false,
sort: true,
func: {
list: ['_'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.3",
"version": "0.8.8",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -35,7 +35,6 @@
"@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",
@@ -55,11 +54,11 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"next": "15.1.0",
"posthog-js": "^1.205.0",
"posthog-js": "^1.194.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-icons": "^5.3.0",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
},
@@ -73,7 +72,7 @@
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.23",
"daisyui": "^4.12.14",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
@@ -84,7 +83,7 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2"
}
}
@@ -1,10 +1,7 @@
{
"(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",
@@ -17,23 +14,17 @@
"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",
@@ -50,8 +41,6 @@
"Grass": "Grasgrün",
"Gray": "Grau",
"Gruvbox": "Gruvbox",
"Highlight": "Hervorheben",
"Horizontal Direction": "Horizontale Richtung",
"Hyphenation": "Silbentrennung",
"Identifier:": "Kennung:",
"Import Books": "Bücher importieren",
@@ -79,8 +68,6 @@
"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",
@@ -96,38 +83,25 @@
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Schreibmodus",
"Your Library": "Ihre Bibliothek"
}
@@ -1,10 +1,7 @@
{
"(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",
@@ -17,23 +14,17 @@
"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",
@@ -50,8 +41,6 @@
"Grass": "Hierba",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Resaltar",
"Horizontal Direction": "Dirección horizontal",
"Hyphenation": "Guionización",
"Identifier:": "Identificador:",
"Import Books": "Importar libros",
@@ -79,8 +68,6 @@
"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",
@@ -96,38 +83,25 @@
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Modo de escritura",
"Your Library": "Tu biblioteca"
}
}
@@ -1,10 +1,7 @@
{
"(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",
@@ -17,23 +14,17 @@
"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",
@@ -50,8 +41,6 @@
"Grass": "Herbe",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Surligner",
"Horizontal Direction": "Direction horizontale",
"Hyphenation": "Césure",
"Identifier:": "Identifiant :",
"Import Books": "Importer des livres",
@@ -79,8 +68,6 @@
"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",
@@ -96,38 +83,25 @@
"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.",
"Wikipedia": "Wikipédia",
"Writing Mode": "Mode écriture",
"Your Library": "Votre bibliothèque"
}
@@ -1,10 +1,7 @@
{
"(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",
@@ -17,23 +14,17 @@
"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",
@@ -50,8 +41,6 @@
"Grass": "Rumput",
"Gray": "Abu-abu",
"Gruvbox": "Gruvbox",
"Highlight": "Sorot",
"Horizontal Direction": "Arah Horizontal",
"Hyphenation": "Pemenggalan Kata",
"Identifier:": "Pengenal:",
"Import Books": "Impor Buku",
@@ -79,8 +68,6 @@
"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",
@@ -96,38 +83,25 @@
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Mode Menulis",
"Your Library": "Perpustakaan Anda"
}
@@ -1,10 +1,7 @@
{
"(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",
@@ -17,23 +14,17 @@
"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",
@@ -50,8 +41,6 @@
"Grass": "Erba",
"Gray": "Grigio",
"Gruvbox": "Gruvbox",
"Highlight": "Evidenzia",
"Horizontal Direction": "Direzione orizzontale",
"Hyphenation": "Sillabazione",
"Identifier:": "Identificatore:",
"Import Books": "Importa libri",
@@ -79,8 +68,6 @@
"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",
@@ -96,38 +83,25 @@
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Modalità scrittura",
"Your Library": "La tua biblioteca"
}
@@ -1,10 +1,7 @@
{
"(detected)": "(検出)",
"About Readest": "Readestについて",
"Add your notes here...": "ここにメモを追加...",
"Animation": "アニメーション",
"Annotate": "注釈",
"Apply": "適用",
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
"Auto Mode": "自動モード",
"Behavior": "動作",
@@ -17,23 +14,17 @@
"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": "書体",
@@ -50,8 +41,6 @@
"Grass": "グリーン",
"Gray": "グレー",
"Gruvbox": "グルーブボックス",
"Highlight": "ハイライト",
"Horizontal Direction": "横書",
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
@@ -79,8 +68,6 @@
"Notebook": "ノート",
"Notes": "メモ",
"Open": "開く",
"Open Book": "書籍を開く",
"Original Text": "原文",
"Override Publisher Font": "出版社フォントを上書き",
"Page": "ページ",
"Paging Animation": "ページめくりアニメーション",
@@ -96,38 +83,25 @@
"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.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
"Wikipedia": "ウィキペディア",
"Writing Mode": "書き込みモード",
"Your Library": "ライブラリー"
}
@@ -1,10 +1,7 @@
{
"(detected)": "(감지됨)",
"About Readest": "Readest 정보",
"Add your notes here...": "여기에 메모를 추가하세요...",
"Animation": "애니메이션",
"Annotate": "주석",
"Apply": "적용",
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
"Auto Mode": "자동 모드",
"Behavior": "동작",
@@ -17,23 +14,17 @@
"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": "글꼴 스타일",
@@ -50,8 +41,6 @@
"Grass": "잔디색",
"Gray": "회색",
"Gruvbox": "그러브박스",
"Highlight": "하이라이트",
"Horizontal Direction": "수평 방향",
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
@@ -79,8 +68,6 @@
"Notebook": "노트북",
"Notes": "메모",
"Open": "열기",
"Open Book": "책 열기",
"Original Text": "원본 텍스트",
"Override Publisher Font": "출판사 글꼴 재정의",
"Page": "페이지",
"Paging Animation": "페이지 넘김 애니메이션",
@@ -96,38 +83,25 @@
"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.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
"Wikipedia": "위키피디아",
"Writing Mode": "글쓰기 모드",
"Your Library": "내 서재"
}
@@ -1,10 +1,7 @@
{
"(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",
@@ -17,23 +14,17 @@
"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",
@@ -50,8 +41,6 @@
"Grass": "Grama",
"Gray": "Cinza",
"Gruvbox": "Gruvbox",
"Highlight": "Destaque",
"Horizontal Direction": "Direção Horizontal",
"Hyphenation": "Hifenização",
"Identifier:": "Identificador:",
"Import Books": "Importar Livros",
@@ -79,8 +68,6 @@
"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",
@@ -96,38 +83,25 @@
"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.",
"Wikipedia": "Wikipédia",
"Writing Mode": "Modo de Escrita",
"Your Library": "Sua Biblioteca"
}
@@ -1,10 +1,7 @@
{
"(detected)": "(обнаружено)",
"About Readest": "О Readest",
"Add your notes here...": "Добавьте свои заметки здесь...",
"Animation": "Анимация",
"Annotate": "Аннотация",
"Apply": "Применить",
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
"Auto Mode": "Автоматический режим",
"Behavior": "Поведение",
@@ -17,23 +14,17 @@
"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": "Начертание шрифта",
@@ -50,8 +41,6 @@
"Grass": "Трава",
"Gray": "Серый",
"Gruvbox": "Gruvbox",
"Highlight": "Выделить",
"Horizontal Direction": "Горизонтальное направление",
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
@@ -79,8 +68,6 @@
"Notebook": "Блокнот",
"Notes": "Заметки",
"Open": "Открыть",
"Open Book": "Открыть книгу",
"Original Text": "Оригинальный текст",
"Override Publisher Font": "Переопределить шрифт издателя",
"Page": "Страница",
"Paging Animation": "Анимация перелистывания",
@@ -96,38 +83,25 @@
"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.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
"Wikipedia": "Википедия",
"Writing Mode": "Режим записи",
"Your Library": "Ваша библиотека"
}
@@ -1,10 +1,7 @@
{
"(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ış",
@@ -17,23 +14,17 @@
"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ü",
@@ -50,8 +41,6 @@
"Grass": "Çimen",
"Gray": "Gri",
"Gruvbox": "Gruvbox",
"Highlight": "Vurgula",
"Horizontal Direction": "Yatay Yön",
"Hyphenation": "Heceleme",
"Identifier:": "Tanımlayıcı:",
"Import Books": "Kitapları İçe Aktar",
@@ -79,8 +68,6 @@
"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",
@@ -96,38 +83,25 @@
"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.",
"Wikipedia": "Vikipedi",
"Writing Mode": "Yazma Modu",
"Your Library": "Kütüphaneniz"
}
@@ -1,10 +1,7 @@
{
"(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",
@@ -17,23 +14,17 @@
"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ữ",
@@ -50,8 +41,6 @@
"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",
@@ -79,8 +68,6 @@
"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",
@@ -96,38 +83,25 @@
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Chế độ viết",
"Your Library": "Thư viện của bạn"
}
@@ -1,10 +1,7 @@
{
"(detected)": "(检测到)",
"About Readest": "关于 Readest",
"Add your notes here...": "在这里添加您的笔记...",
"Animation": "动画",
"Annotate": "笔记",
"Apply": "应用",
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
"Auto Mode": "自动主题",
"Behavior": "行为",
@@ -17,23 +14,17 @@
"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": "字型",
@@ -50,8 +41,6 @@
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "划线",
"Horizontal Direction": "横排",
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
@@ -79,10 +68,8 @@
"Notebook": "笔记本",
"Notes": "笔记",
"Open": "打开",
"Open Book": "打开书籍",
"Original Text": "原文",
"Override Publisher Font": "覆盖内置字体",
"Page": "页",
"Page": "页",
"Paging Animation": "翻页动画",
"Paragraph": "段落",
"Parallel Read": "并排阅读",
@@ -96,38 +83,25 @@
"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.": "书库空空如也,您可以导入您的书籍并随时阅读。",
"Wikipedia": "维基百科",
"Writing Mode": "排版模式",
"Your Library": "书库"
}
@@ -1,10 +1,7 @@
{
"(detected)": "(檢測到)",
"About Readest": "關於 Readest",
"Add your notes here...": "在這裡添加您的筆記...",
"Animation": "動畫",
"Annotate": "筆記",
"Apply": "應用",
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
"Auto Mode": "自動主題",
"Behavior": "行為",
@@ -17,23 +14,17 @@
"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": "字型",
@@ -50,8 +41,6 @@
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "劃線",
"Horizontal Direction": "橫排",
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
@@ -79,10 +68,8 @@
"Notebook": "筆記本",
"Notes": "筆記",
"Open": "打開",
"Open Book": "打開書籍",
"Original Text": "原文",
"Override Publisher Font": "覆蓋內置字體",
"Page": "頁",
"Page": "頁",
"Paging Animation": "翻頁動畫",
"Paragraph": "段落",
"Parallel Read": "並排閱讀",
@@ -96,38 +83,25 @@
"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.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
"Wikipedia": "維基百科",
"Writing Mode": "排版模式",
"Your Library": "書庫"
}
+1 -39
View File
@@ -1,43 +1,5 @@
{
"releases": {
"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": [
@@ -82,7 +44,7 @@
"notes": [
"Support file associations for one-click open with Readest.",
"Support APP auto updater.",
"Support online web version."
"Suppport online web version."
]
},
"0.7.9": {
+126 -295
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -6,7 +6,7 @@ authors = ["Bilingify LLC"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
rust-version = "1.71"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -34,7 +34,6 @@ 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" }
@@ -45,6 +44,4 @@ 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,21 +51,17 @@
"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",
"deep-link:default"
"opener:default"
]
}
@@ -1,10 +0,0 @@
{
"$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: 84 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 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

+10 -55
View File
@@ -9,7 +9,7 @@ extern crate objc;
#[cfg(target_os = "macos")]
mod menu;
#[cfg(target_os = "macos")]
mod traffic_light_plugin;
mod tauri_traffic_light_positioner_plugin;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -70,16 +70,11 @@ 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())
@@ -89,26 +84,8 @@ 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(traffic_light_plugin::init());
let builder = builder.plugin(tauri_traffic_light_positioner_plugin::init());
builder
.setup(|#[allow(unused_variables)] app| {
@@ -117,7 +94,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 also be passed as `file://path/to/file`
// files may aslo 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("-") {
@@ -143,25 +120,8 @@ pub fn run() {
});
}
}
#[cfg(desktop)]
{
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()?;
}
app.handle().plugin(tauri_plugin_cli::init())?;
#[cfg(desktop)]
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -170,13 +130,10 @@ pub fn run() {
.build(),
)?;
}
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
#[cfg(desktop)]
let win_builder = win_builder
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.inner_size(800.0, 600.0)
.resizable(true);
.resizable(true)
.maximized(true);
#[cfg(target_os = "macos")]
let win_builder = win_builder
@@ -184,12 +141,10 @@ pub fn run() {
.title_bar_style(TitleBarStyle::Overlay)
.title("");
#[cfg(all(not(target_os = "macos"), desktop))]
#[cfg(not(target_os = "macos"))]
let win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.shadow(true)
.title("Readest");
win_builder.build().unwrap();
@@ -208,7 +163,7 @@ pub fn run() {
.run(
#[allow(unused_variables)]
|app_handle, event| {
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "ios"))]
if let tauri::RunEvent::Opened { urls } = event {
let files = urls
.into_iter()
+2 -8
View File
@@ -15,7 +15,7 @@
"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 wss://speech.platform.bing.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",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
"frame-src": "'self' blob: asset: http://asset.localhost",
@@ -130,14 +130,8 @@
}
]
},
"deep-link": {
"mobile": [{ "host": "web.readest.com" }],
"desktop": {
"schemes": ["readest"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
}
}
+26 -58
View File
@@ -17,18 +17,12 @@ 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;
@@ -70,10 +64,7 @@ export default function AuthPage() {
provider,
options: {
skipBrowserRedirect: true,
redirectTo:
process.env.NODE_ENV === 'production'
? 'readest://auth/callback'
: `http://localhost:${port}`,
redirectTo: `http://localhost:${port}`,
},
});
@@ -84,54 +75,34 @@ export default function AuthPage() {
openUrl(data.url);
};
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 () => {
const startOAuthServer = async () => {
try {
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}`);
const port = await start();
setPort(port);
console.log(`OAuth server started on port ${port}`);
await onUrl(handleOAuthUrl);
await onInvalidUrl((url) => {
console.log('Received invalid OAuth URL:', url);
});
}
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);
});
} catch (error) {
console.error('Error starting OAuth server:', error);
}
};
const stopTauriOAuth = async () => {
const stopOAuthServer = async () => {
try {
if (port) {
await cancel(port);
@@ -155,10 +126,10 @@ export default function AuthPage() {
if (isOAuthServerRunning.current) return;
isOAuthServerRunning.current = true;
startTauriOAuth();
startOAuthServer();
return () => {
isOAuthServerRunning.current = false;
stopTauriOAuth();
stopOAuthServer();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -185,16 +156,13 @@ 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} className='text-base-content' />
<IoArrowBack size={20} />
</button>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<ProviderLogin
@@ -238,7 +206,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} className='text-base-content' />
<IoArrowBack size={20} />
</button>
<Auth
supabaseClient={supabase}
@@ -20,10 +20,10 @@ 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';
type BookshelfItem = Book | BooksGroup;
@@ -71,14 +71,16 @@ 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 [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const showBookDetailsModal = (book: Book) => {
setShowDetailsBook(book);
const showMoreDetails = (book: Book) => {
setIsModalOpen(true);
setSelectedBook(book);
};
const dismissBookDetailsModal = () => {
setShowDetailsBook(null);
const closeModal = () => {
setIsModalOpen(false);
};
const { setLibrary } = useLibraryStore();
@@ -152,12 +154,6 @@ 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 () => {
@@ -165,12 +161,6 @@ 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 () => {
@@ -178,8 +168,6 @@ 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();
@@ -191,7 +179,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
{bookshelfItems.map((item, index) => (
<div
key={`library-item-${index}`}
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
className='hover:bg-base-300/50 flex h-full flex-col p-4'
>
<div className='flex-grow'>
{'format' in item ? (
@@ -242,19 +230,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
)}
</div>
</div>
<div className='flex flex-row items-center justify-between p-0 pt-2'>
<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>
{isWebAppPlatform() && (
<div
className='show-detail-button self-start opacity-0 group-hover:opacity-100'
role='button'
onClick={showBookDetailsModal.bind(null, item as Book)}
>
<CiCircleMore size={15} />
</div>
)}
<div
className='card-detail'
role='button'
onClick={showMoreDetails.bind(null, item as Book)}
>
<CiCircleMore size={15} />
</div>
</div>
</div>
) : (
@@ -315,12 +301,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
/>
)}
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={dismissBookDetailsModal}
/>
{selectedBook && (
<BookDetailModal isOpen={isModalOpen} book={selectedBook} onClose={closeModal} />
)}
</div>
);
@@ -71,7 +71,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</div>
<ul
tabIndex={-1}
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
className='dropdown-content dropdown-center menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
>
<li>
<button className='text-base-content' onClick={onImportBooks}>
+8 -31
View File
@@ -7,10 +7,8 @@ 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, hasUpdater } from '@/services/environment';
import { isTauriAppPlatform } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
@@ -22,11 +20,10 @@ 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();
@@ -49,7 +46,7 @@ const LibraryPage = () => {
useEffect(() => {
const doAppUpdates = async () => {
if (hasUpdater()) {
if (isTauriAppPlatform()) {
await checkForAppUpdates();
}
};
@@ -60,14 +57,9 @@ const LibraryPage = () => {
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const bookIds: string[] = [];
for (const file of openWithFiles) {
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);
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
}
}
setLibrary(libraryBooks);
@@ -157,23 +149,9 @@ const LibraryPage = () => {
const importBooks = async (files: [string | File]) => {
setLoading(true);
const failedFiles = [];
for (const file of files) {
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);
}
await appService?.importBook(file, libraryBooks);
setLibrary(libraryBooks);
}
appService?.saveLibraryBooks(libraryBooks);
setLoading(false);
@@ -275,7 +253,6 @@ const LibraryPage = () => {
</div>
))}
<AboutWindow />
<Toast />
</div>
);
};
@@ -2,7 +2,6 @@ import React from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
@@ -12,6 +11,7 @@ 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 {
@@ -49,7 +49,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className='rounded-window relative h-full w-full overflow-hidden'
className='relative h-full w-full overflow-hidden'
>
{isBookmarked && <Ribbon width={marginGap} />}
<HeaderBar
@@ -18,6 +18,7 @@ import {
handleClick,
handleWheel,
} from '../utils/iframeEventHandlers';
import Toast from '@/components/Toast';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -38,7 +39,7 @@ const FoliateViewer: React.FC<{
return () => clearTimeout(timer);
}, [toastMessage]);
useProgressSync(bookKey);
useProgressSync(bookKey, setToastMessage);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -163,6 +164,9 @@ const FoliateViewer: React.FC<{
onClick={(event) => handleTurnPage(event)}
ref={containerRef}
/>
{toastMessage && (
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
)}
</>
);
};
@@ -2,12 +2,10 @@ 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 {
@@ -18,10 +16,9 @@ interface FooterBarProps {
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const _ = useTranslation();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey, getView } = 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);
@@ -44,12 +41,6 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
view?.history.forward();
};
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
eventDispatcher.dispatch('tts-speak', { bookKey, range });
};
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
return (
@@ -92,7 +83,6 @@ 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,7 +28,6 @@ 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(() => {
@@ -74,13 +73,7 @@ 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,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
@@ -114,9 +107,6 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
}
}, [footnoteRef]);
const width = viewSettings.vertical ? popupHeight : popupWidth;
const height = viewSettings.vertical ? popupWidth : popupHeight;
return (
<div>
{showPopup && (
@@ -127,8 +117,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
/>
)}
<Popup
width={width}
height={height}
width={popupWidth}
height={popupHeight}
position={showPopup ? popupPosition! : undefined}
trianglePosition={showPopup ? trianglePosition! : undefined}
className='select-text overflow-y-auto'
@@ -137,8 +127,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
className=''
ref={footnoteRef}
style={{
width: `${width}px`,
height: `${height}px`,
width: `${popupWidth}px`,
height: `${popupHeight}px`,
}}
></div>
</Popup>
@@ -5,10 +5,10 @@ import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
import { AboutWindow } from '@/components/AboutWindow';
import { useSettingsStore } from '@/store/settingsStore';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
@@ -33,11 +33,10 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
return (
library.length > 0 &&
settings.globalReadSettings && (
<div className='reader-page rounded-window bg-base-100 text-base-content min-h-screen select-none'>
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<Toast />
</Suspense>
</div>
)
@@ -9,24 +9,21 @@ 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 TTSControl from './tts/TTSControl';
import { eventDispatcher } from '@/utils/event';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
@@ -38,7 +35,6 @@ 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);
@@ -63,13 +59,6 @@ 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
}, []);
@@ -89,20 +78,14 @@ 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);
try {
getView(bookKey)?.close();
getView(bookKey)?.remove();
} catch {
console.info('Error closing book', bookKey);
}
eventDispatcher.dispatch('tts-stop', { bookKey });
getView(bookKey)?.close();
getView(bookKey)?.remove();
await saveBookConfig(bookKey);
clearViewState(bookKey);
};
@@ -113,7 +96,6 @@ 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);
};
@@ -156,15 +138,7 @@ 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 bgcolor-base-200 dropdown-right no-triangle border-base-200 z-20 mt-1 w-72 border shadow-2xl'
className='view-menu dropdown-content dropdown-right no-triangle border-base-100 z-20 mt-1 w-72 border shadow-2xl'
>
<div className={clsx('flex items-center justify-between rounded-md')}>
<button
@@ -6,8 +6,7 @@ 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 { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import { SiDeepl } from 'react-icons/si';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
@@ -18,18 +17,17 @@ 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();
@@ -56,6 +54,7 @@ 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>(
@@ -83,9 +82,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelection({ key: bookKey, text: sel.toString(), range: sel.getRangeAt(0), index });
}
};
if (bookData.book?.format !== 'PDF') {
detail.doc?.addEventListener('pointerup', handlePointerup);
}
detail.doc?.addEventListener('pointerup', handlePointerup);
};
const onDrawAnnotation = (event: Event) => {
@@ -194,6 +191,11 @@ 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;
@@ -218,12 +220,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleCopy = () => {
if (!selection || !selection.text) return;
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to notebook'),
className: 'whitespace-nowrap',
timeout: 2000,
});
setToastMessage('Copied to notebook');
const { booknotes: annotations = [] } = config;
if (selection) navigator.clipboard.writeText(selection.text);
@@ -335,26 +332,19 @@ 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: TbHexagonLetterD, onClick: handleDictionary },
{ tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation },
{ tooltipText: _('Speak'), Icon: FaHeadphones, onClick: handleSpeakText },
{ 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 },
];
return (
@@ -409,6 +399,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onHighlight={handleHighlight}
/>
)}
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
</div>
);
};
@@ -2,7 +2,6 @@ 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 = {
@@ -38,7 +37,6 @@ 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);
@@ -126,7 +124,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}
@@ -136,7 +134,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>
);
@@ -148,7 +146,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}
@@ -165,7 +163,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,8 +51,7 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
>
<div
className={clsx(
'w-4 p-0 text-center leading-none',
style === 'highlight' ? 'h-4' : 'h-5',
'h-4 w-4 p-0 text-center leading-none',
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: (selection: TextSelection, note: string) => void;
onSave: (selction: 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-sm',
'text-base',
)}
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.3em] min-h-[1.3em] items-end p-0',
'flex h-[1.5em] min-h-[1.5em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={handleSaveNote}
@@ -160,15 +160,21 @@ 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'>
<div
className='collapse-content select-text px-3 pb-0 text-xs'
onClick={(e) => e.stopPropagation()}
>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end'>
<div
className='cursor-pointer align-bottom text-xs text-red-400'
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1em] min-h-[1em] items-end p-0',
)}
onClick={handleEditNote.bind(null, item, true)}
>
{_('Delete')}
</div>
<div className='align-bottom text-xs text-red-400'>{_('Delete')}</div>
</button>
</div>
</div>
</div>
@@ -1,14 +1,11 @@
import clsx from 'clsx';
import React from 'react';
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
import { FiChevronDown } 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;
}
@@ -17,23 +14,21 @@ const FontDropdown: React.FC<DropdownProps> = ({
family,
selected,
options,
moreOptions,
onSelect,
onGetFontFamily,
}) => {
const _ = useTranslation();
return (
<div className='dropdown dropdown-top'>
<div className='dropdown dropdown-end'>
<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>
<FiChevronUp className='h-4 w-4' />
<FiChevronDown className='h-4 w-4' />
</button>
<ul
tabIndex={0}
className='dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-0 z-[1] mt-4 w-44 shadow'
className='dropdown-content dropdown-right menu bg-base-100 rounded-box z-[1] mt-4 w-44 shadow'
>
{options.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
@@ -45,36 +40,6 @@ 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] max-h-80 w-[292px]',
)}
>
{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,27 +3,18 @@ import React, { useEffect, useState } from 'react';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
import {
LINUX_FONTS,
MACOS_FONTS,
MONOSPACE_FONTS,
SANS_SERIF_FONTS,
SERIF_FONTS,
WINDOWS_FONTS,
} from '@/services/constants';
import { MONOSPACE_FONTS, SANS_SERIF_FONTS, SERIF_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;
}
@@ -34,21 +25,12 @@ const handleFontFaceFont = (option: string, family: string) => {
return `'${option}', ${family}`;
};
const FontFace = ({
className,
family,
label,
options,
moreOptions,
selected,
onSelect,
}: FontFaceProps) => (
const FontFace = ({ className, family, label, options, 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}
@@ -64,21 +46,6 @@ 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!);
@@ -239,7 +206,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='serif'
label={_('Serif Font')}
options={SERIF_FONTS}
moreOptions={moreFonts}
selected={serifFont}
onSelect={setSerifFont}
/>
@@ -247,7 +213,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='sans-serif'
label={_('Sans-Serif Font')}
options={SANS_SERIF_FONTS}
moreOptions={moreFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
/>
@@ -256,7 +221,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='monospace'
label={_('Monospace Font')}
options={MONOSPACE_FONTS}
moreOptions={moreFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
/>
@@ -1,24 +1,17 @@
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 { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getBookLangCode } from '@/utils/book';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import NumberInput from './NumberInput';
import { useTheme } from '@/hooks/useTheme';
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();
@@ -30,7 +23,6 @@ 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;
@@ -119,66 +111,8 @@ 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'>
@@ -239,7 +173,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
value={maxColumnCount}
onChange={setMaxColumnCount}
min={1}
max={4}
max={2}
/>
<NumberInput
label={_('Maximum Inline Size')}
@@ -1,12 +1,13 @@
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';
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
const cssRegex =
/((?:\s*)([\w#.@*,:\-.:>+~$$$$\"=(),*\s]+)\s*{(?:[\s]*)((?:[^\}]+[:][^\}]+;?)*)*\s*}(?:\s*))/gim;
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -17,49 +18,42 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [userStylesheet, setUserStylesheet] = useState(viewSettings.userStylesheet!);
const [error, setError] = useState<string | null>(null);
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const cssInput = e.target.value;
setDraftStylesheet(cssInput);
setDraftStylesheetSaved(false);
let cssInput = userStylesheet;
try {
const { isValid, error } = cssValidate(cssInput);
if (cssInput && !isValid) {
throw new Error(error || 'Invalid CSS');
}
setError(null);
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Invalid CSS: Please check your input.');
}
console.log('CSS Error:', err);
}
const validateCSS = (css: string) => {
return cssRegex.test(css);
};
const applyStyles = () => {
const formattedCSS = cssbeautify(draftStylesheet, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
cssInput = e.target.value;
setDraftStylesheet(formattedCSS);
setDraftStylesheetSaved(true);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
try {
const formattedCSS = cssbeautify(cssInput, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setUserStylesheet(formattedCSS);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
if (cssInput && !validateCSS(cssInput)) {
throw new Error('Invalid CSS');
}
setError(null);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
} catch (err) {
setError('Invalid CSS: Please check your input.');
console.log('CSS Error:', err);
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
};
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
@@ -96,10 +90,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 border-base-200 bg-base-100 border shadow'>
<div className='card bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className=''>{_('Paging Animation')}</span>
<span className='text-gray-700'>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
@@ -113,10 +107,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='card bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className=''>{_('Disable Click-to-Flip')}</span>
<span className='text-gray-700'>{_('Disable Click-to-Flip')}</span>
<input
type='checkbox'
className='toggle'
@@ -130,34 +124,22 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Custom CSS')}</h2>
<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 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>
</div>
{error && <p className='mt-1 text-sm text-red-500'>{error}</p>}
</div>
</div>
);
@@ -25,17 +25,11 @@ const NumberInput: React.FC<NumberInputProps> = ({
const numberStep = step || 1;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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)));
}
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)));
}
};
@@ -65,7 +59,6 @@ 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}
@@ -39,7 +39,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
return (
<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-[540px] max-w-full flex-col p-0'>
<div className='modal-box settings-content flex h-[60%] 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'
className='dropdown-bottom dropdown-end absolute right-[7%]'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={16} />}
>
@@ -1,22 +1,20 @@
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';
const BookCard = ({ book }: { book: Book }) => {
const { coverImageUrl, title, author } = book;
interface BookCardProps {
cover: string;
title: string;
author: string;
}
const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
const _ = useTranslation();
const showBookDetails = () => {
eventDispatcher.dispatchSync('show-book-details', book);
};
return (
<div className='flex h-20 w-full items-center'>
<Image
src={coverImageUrl!}
src={cover}
alt={_('Book Cover')}
width={56}
height={80}
@@ -33,7 +31,7 @@ const BookCard = ({ book }: { book: Book }) => {
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' onClick={showBookDetails} />
<MdInfoOutline size={18} className='fill-base-content' />
</button>
</div>
);
@@ -122,12 +122,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
)}
onClick={editNote.bind(null, item)}
>
<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>
<div className='align-bottom text-blue-400'>{_('Edit')}</div>
</button>
)}
<button
@@ -137,12 +132,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
)}
onClick={deleteNote.bind(null, item)}
>
<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>
<div className='align-bottom text-red-400'>{_('Delete')}</div>
</button>
</div>
</div>
@@ -15,8 +15,6 @@ 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;
@@ -51,17 +49,10 @@ 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 (
@@ -72,20 +63,14 @@ const SidebarContent: React.FC<{
'font-sans text-sm font-normal shadow-inner',
)}
>
<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 && (
<div ref={scrollContainerRef} className='scroll-container overflow-y-auto'>
{activeTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
)}
{targetTab === 'annotations' && (
{activeTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
{targetTab === 'bookmarks' && (
{activeTab === 'bookmarks' && (
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
</div>
@@ -10,8 +10,7 @@ import { BookSearchConfig, BookSearchResult } from '@/types/book';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
const MINIMUM_SEARCH_TERM_LENGTH_DEFAULT = 2;
const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
const MINIMUM_SEARCH_TERM_LENGTH = 2;
interface SearchBarProps {
isVisible: boolean;
@@ -98,15 +97,8 @@ 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 (exceedMinSearchTermLength(term)) {
if (term.length >= MINIMUM_SEARCH_TERM_LENGTH) {
handleSearch(term);
} else {
resetSearch();
@@ -126,10 +118,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
onSearchResultChange([...results]);
isSearchPending.current = false;
console.log('search done');
if (
queuedSearchTerm.current !== term &&
exceedMinSearchTermLength(queuedSearchTerm.current)
) {
if (queuedSearchTerm.current !== term && queuedSearchTerm.current.length > 2) {
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-base-100',
isCurrent ? 'bg-base-300 hover:bg-gray-300/70' : 'hover:bg-base-300 bg-white',
)}
onClick={() => onSelectResult(cfi)}
>
@@ -125,7 +125,7 @@ const SideBar: React.FC<{
/>
</div>
<div className='border-base-300/50 border-b px-3'>
<BookCard book={book} />
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
</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: 'instant', block: 'center' });
(currentItem as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
@@ -1,54 +1,23 @@
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 }) => {
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>
)
};
}> = ({ 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>
);
export default TabNavigation;
@@ -1,257 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
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 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 { 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);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
};
// 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);
if (!view || !viewSettings) 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 } = event.detail;
if (bookKey === bookKey) {
handleStop();
}
};
const handleTogglePlay = async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
if (isPlaying) {
await ttsController.pause();
setIsPlaying(false);
setIsPaused(true);
} else if (isPaused) {
// start for forward/backward/setvoice-paused
// set rate don't pause the tts
if (ttsController.state === 'paused') {
await ttsController.resume();
} else {
await ttsController.start();
}
setIsPlaying(true);
setIsPaused(false);
}
};
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;
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
}
};
// rate range: 0.5 - 3, 1.0 is normal speed
const handleSetRate = async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.pause();
await ttsController.setRate(rate);
await ttsController.start();
} else {
await ttsController.setRate(rate);
}
}
};
const handleSetVoice = async (voice: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.pause();
await ttsController.setVoice(voice);
await ttsController.start();
} else {
await ttsController.setVoice(voice);
}
}
};
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;
@@ -1,54 +0,0 @@
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;
@@ -1,161 +0,0 @@
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 rounded-box absolute right-0 z-[1] shadow',
'mt-4 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,10 +7,12 @@ 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) => {
export const useProgressSync = (
bookKey: string,
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
) => {
const _ = useTranslation();
const { getConfig, setConfig } = useBookDataStore();
const { getView } = useReaderStore();
@@ -19,8 +21,6 @@ export const useProgressSync = (bookKey: string) => {
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,26 +31,13 @@ export const useProgressSync = (bookKey: string) => {
);
syncConfigs([compressedConfig], bookHash, 'push');
};
const pullConfig = (bookKey: string) => {
if (!user) return;
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
};
const syncConfig = () => {
if (!configSynced.current) {
pullConfig(bookKey);
} else {
pushConfig(bookKey, config);
}
};
useEffect(() => {
if (!user) return;
pullConfig(bookKey);
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
return () => {
if (configSynced.current) {
pushConfig(bookKey, config);
}
pushConfig(bookKey, config);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -59,19 +46,18 @@ export const useProgressSync = (bookKey: string) => {
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastProgressSyncTime.current;
if (timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
lastProgressSyncTime.current = now;
syncConfig();
pushConfig(bookKey, config);
} 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,
);
@@ -80,9 +66,9 @@ export const useProgressSync = (bookKey: string) => {
}, [config]);
// sync progress once when the book is opened
const configSynced = useRef(false);
useEffect(() => {
if (!configSynced.current && syncedConfigs) {
configSynced.current = true;
if (!configSynced.current && syncedConfigs?.length > 0) {
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const newConfig = deserializeConfig(
@@ -91,15 +77,13 @@ export const useProgressSync = (bookKey: string) => {
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);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Reading progress synced'),
});
setToastMessage?.(_('Reading progress synced'));
}
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
import { useEffect } from 'react';
import { useTheme } from '@/hooks/useTheme';
import { hasUpdater } from '@/services/environment';
import { isTauriAppPlatform } 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 (hasUpdater()) {
if (isTauriAppPlatform()) {
await checkForAppUpdates();
}
};
@@ -9,7 +9,6 @@ import { useTranslation } from '@/hooks/useTranslation';
import { formatDate, formatSubject } from '@/utils/book';
import WindowButtons from '@/components/WindowButtons';
import Spinner from './Spinner';
import { BookDoc } from '@/libs/document';
interface BookDetailModalProps {
book: Book;
@@ -20,25 +19,19 @@ interface BookDetailModalProps {
const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
const _ = useTranslation();
const [loading, setLoading] = useState(false);
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
const [bookMeta, setBookMeta] = useState<null | {
title: string;
language: string | string[];
editor?: string;
publisher?: string;
published?: string;
description?: string;
subject?: string[];
identifier?: string;
}>(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 () => {
@@ -105,10 +98,10 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
</div>
</div>
<div className='title-author flex h-40 max-w-[60%] flex-col justify-between pr-4'>
<div className='title-author flex h-40 flex-col justify-between pr-4'>
<div>
<h2 className='text-base-content mb-2 line-clamp-2 break-all text-2xl font-bold'>
{book.title || _('Untitled')}
<h2 className='text-base-content mb-2 line-clamp-2 text-2xl font-bold'>
{bookMeta.title || _('Untitled')}
</h2>
<p className='text-neutral-content line-clamp-1'>{book.author || _('Unknown')}</p>
</div>
+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-40 ${triangleClassName}`}
className={`triangle text-base-200 absolute z-10 ${triangleClassName}`}
style={{
left:
trianglePosition?.dir === 'left'
@@ -67,7 +67,7 @@ const Popup = ({
/>
<div
id='popup-container'
className={`bg-base-200 absolute z-30 rounded-lg font-sans shadow-xl ${className}`}
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
height: `${height}px`,
+13 -62
View File
@@ -1,65 +1,16 @@
import { eventDispatcher } from '@/utils/event';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';
export type ToastType = 'info' | 'success' | 'warning' | 'error';
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 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>
)
);
};
export default Toast;
@@ -1,6 +1,5 @@
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';
@@ -45,7 +44,6 @@ 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;
@@ -53,7 +51,6 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
if (
target.closest('.btn') ||
target.closest('.window-button') ||
target.closest('.dropdown-container') ||
target.closest('.exclude-title-bar-mousedown')
) {
return;
@@ -113,7 +110,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
className,
)}
>
{showMinimize && appService?.hasWindowBar && (
{showMinimize && (
<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' />
@@ -121,7 +118,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
</WindowButton>
)}
{showMaximize && appService?.hasWindowBar && (
{showMaximize && (
<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' />
@@ -129,7 +126,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
</WindowButton>
)}
{showClose && (appService?.hasWindowBar || onClose) && (
{showClose && (
<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
+16 -36
View File
@@ -29,43 +29,27 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
});
useEffect(() => {
const syncSession = (
session: { access_token: string; refresh_token: string; user: User } | null,
) => {
const fetchSession = async () => {
const {
data: { session },
} = await supabase.auth.getSession();
if (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));
const { access_token, user } = session;
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);
}
};
const { data: subscription } = supabase.auth.onAuthStateChange((_, session) => {
syncSession(session);
});
refreshSession();
return () => {
subscription?.subscription.unsubscribe();
};
}, []);
console.log('Fetching session');
fetchSession();
}, [token]);
const login = (newToken: string, newUser: User) => {
console.log('Logging in');
@@ -77,16 +61,12 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const logout = async () => {
console.log('Logging out');
try {
await supabase.auth.refreshSession();
} catch {
} finally {
await supabase.auth.signOut();
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}
await supabase.auth.refreshSession();
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, hasCli } from '@/services/environment';
import { isWebAppPlatform } from '@/services/environment';
declare global {
interface Window {
@@ -36,7 +36,7 @@ export const parseOpenWithFiles = async () => {
if (isWebAppPlatform()) return [];
let files = parseWindowOpenWithFiles();
if (!files && hasCli()) {
if (!files) {
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'],
+15 -18
View File
@@ -51,15 +51,14 @@ 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: null,
configs: null,
notes: null,
books: [],
configs: [],
notes: [],
});
const [syncedBooks, setSyncedBooks] = useState<Book[] | null>(null);
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[] | null>(null);
const [syncedNotes, setSyncedNotes] = useState<BookNote[] | null>(null);
const [syncedBooks, setSyncedBooks] = useState<Book[]>([]);
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[]>([]);
const [syncedNotes, setSyncedNotes] = useState<BookNote[]>([]);
const { syncClient } = useSyncContext();
@@ -77,11 +76,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(records);
if (!records.length) return;
const maxTime = computeMaxTimestamp(result[type]);
setLastSyncedAt(maxTime);
setSyncResult(result);
switch (type) {
case 'books':
settings.lastSyncedAtBooks = maxTime;
@@ -170,18 +169,16 @@ 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) setSyncedBooks(books);
if (configs) setSyncedConfigs(configs);
if (notes) setSyncedNotes(notes);
if (books.length) setSyncedBooks(books);
if (configs.length) setSyncedConfigs(configs);
if (notes.length) setSyncedNotes(notes);
}
}, [syncResult, syncing]);
+2 -8
View File
@@ -1,5 +1,4 @@
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
@@ -52,16 +51,11 @@ export interface SectionItem {
export interface BookDoc {
metadata: {
// NOTE: the title and author fields should be formatted
title: string | LanguageMap;
author: string | Contributor;
title: string;
author: string;
language: string | string[];
editor?: string;
publisher?: string;
published?: string;
description?: string;
subject?: string[];
identifier?: string;
};
toc?: Array<TOCItem>;
sections?: Array<SectionItem>;
-285
View File
@@ -1,285 +0,0 @@
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;
}
}
}
+3 -9
View File
@@ -18,9 +18,9 @@ interface BookConfigRecord extends BookDataRecord, BookConfig {}
interface BookNoteRecord extends BookDataRecord, BookNote {}
export interface SyncResult {
books: BookRecord[] | null;
notes: BookNoteRecord[] | null;
configs: BookConfigRecord[] | null;
books: BookRecord[];
notes: BookNoteRecord[];
configs: BookConfigRecord[];
}
export interface SyncData {
@@ -79,12 +79,6 @@ 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,7 +25,6 @@ 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';
@@ -36,7 +35,6 @@ 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;
@@ -69,7 +67,6 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...settings.globalViewSettings,
};
} catch {
@@ -86,7 +83,6 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
},
};
+2 -171
View File
@@ -1,11 +1,4 @@
import {
BookFont,
BookLayout,
BookSearchConfig,
BookStyle,
TTSConfig,
ViewConfig,
} from '@/types/book';
import { BookFont, BookLayout, BookSearchConfig, BookStyle, ViewConfig } from '@/types/book';
import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
@@ -15,7 +8,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: '15%',
sideBarWidth: '25%',
isSideBarPinned: true,
notebookWidth: '25%',
isNotebookPinned: false,
@@ -49,7 +42,6 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
maxInlineSize: 720,
maxBlockSize: 1440,
animated: false,
writingMode: 'auto',
vertical: false,
};
@@ -68,11 +60,6 @@ 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,
@@ -95,162 +82,6 @@ export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
export const WINDOWS_FONTS = [
'Arial',
'Arial Black',
'Bahnschrift',
'Calibri',
'Cambria',
'Cambria Math',
'Candara',
'Comic Sans MS',
'Consolas',
'Constantia',
'Corbel',
'Courier New',
'Ebrima',
'Franklin Gothic Medium',
'Gabriola',
'Gadugi',
'Georgia',
'HoloLens MDL2 Assets',
'Impact',
'Ink Free',
'Javanese Text',
'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-ExtB',
'Mongolian Baiti',
'MS Gothic',
'MV Boli',
'Myanmar Text',
'Nirmala UI',
'Palatino Linotype',
'Segoe MDL2 Assets',
'Segoe Print',
'Segoe Script',
'Segoe UI',
'Segoe UI Historic',
'Segoe UI Emoji',
'Segoe UI Symbol',
'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',
'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',
'Helvetica',
'Helvetica Neue',
'Herculanum',
'Hoefler Text',
'Impact',
'Lucida Grande',
'Luminari',
'Marker Felt',
'Menlo',
'Microsoft Sans Serif',
'Monaco',
'Noteworthy',
'Optima',
'Palatino',
'Papyrus',
'Phosphate',
'Rockwell',
'Savoye LET',
'SignPainter',
'Skia',
'Snell Roundhand',
'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 Serif',
'Open Sans',
'Poppins',
'Symbola',
'Times New Roman',
'Ubuntu',
'Ubuntu Mono',
'Wingdings',
];
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
export const BOOK_IDS_SEPARATOR = '+';
@@ -1,16 +1,7 @@
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,7 +119,6 @@ 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);
@@ -1,199 +0,0 @@
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): 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;
}
await this.stopInternal();
// Preloading for longer ssml
if (marks.length > 1) {
for (const mark of marks.slice(1)) {
this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((error) => {
console.warn('Error preloading mark:', mark, error);
});
}
}
for (const mark of marks) {
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}`,
});
};
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) {
console.error('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 || '';
}
}
@@ -1,31 +0,0 @@
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): 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;
}
@@ -1,186 +0,0 @@
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'
| 'setvoice-paused';
export class TTSController extends EventTarget {
state: TTSState = 'stopped';
view: FoliateView;
#nossmlCnt: number = 0;
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.ttsClient.stop();
await this.view.initTTS(granularity);
}
async #speak(ssml: string | undefined | Promise<string>) {
console.log('TTS speak');
this.state = 'playing';
ssml = await 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);
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();
}
}
async speak(ssml: string | Promise<string>) {
await this.initViewTTS();
this.#speak(ssml).catch((e) => this.error(e));
}
play() {
if (this.state !== 'playing') {
this.start();
} else {
this.pause();
}
}
async start() {
await this.initViewTTS();
const resumeOrStart = this.state.includes('paused')
? this.view.tts?.resume()
: this.view.tts?.start();
return this.#speak(resumeOrStart);
}
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';
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());
} else {
this.state = 'forward-paused';
this.view.tts?.next(true);
}
}
async setRate(rate: number) {
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';
await this.ttsClient.stop();
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();
}
}
@@ -1,306 +0,0 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { AsyncQueue } from '@/utils/queue';
import { findSSMLMark, parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
const BLACKLISTED_VOICES = [
'Albert',
'Bad News',
'Bahh',
'Bells',
'Boing',
'Bubbles',
'Cellos',
'Eddy',
'Flo',
'Fred',
'Good News',
'Grandma',
'Grandpa',
'Jester',
'Junior',
'Kathy',
'Organ',
'Ralph',
'Reed',
'Rocko',
'Sandy',
'Shelley',
'Superstar',
'Trinoids',
'Whisper',
'Wobble',
'Zarvox',
];
interface TTSBoundaryEvent {
type: 'boundary' | 'end' | 'error';
speaking: boolean;
name?: string;
mark?: string;
charIndex?: number;
charLength?: number;
error?: string;
}
async function* speakWithBoundary(
ssml: string,
getRate: () => number,
getPitch: () => number,
getVoice: () => SpeechSynthesisVoice | null,
) {
const lang = parseSSMLLang(ssml);
const { plainText, marks } = parseSSMLMarks(ssml);
// console.log('ssml', ssml, marks);
// console.log('text', plainText);
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance(plainText);
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
if (lang) {
utterance.lang = lang;
}
const queue = new AsyncQueue<TTSBoundaryEvent>();
utterance.onboundary = (event: SpeechSynthesisEvent) => {
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
const mark = findSSMLMark(event.charIndex, marks);
// console.log('boundary', event.charIndex, mark);
queue.enqueue({
type: 'boundary',
speaking: true,
name: event.name,
mark: mark?.name ?? '',
charIndex: event.charIndex,
charLength: event.charLength,
});
};
utterance.onend = () => {
queue.enqueue({ type: 'end', speaking: false });
queue.finish();
};
utterance.onerror = (event) => {
queue.enqueue({ type: 'error', speaking: false, error: event.error });
queue.finish();
};
synth.speak(utterance);
while (true) {
const ev = await queue.dequeue();
if (ev === null) {
break;
}
yield ev;
}
}
async function* speakWithMarks(
ssml: string,
getRate: () => number,
getPitch: () => number,
getVoice: () => SpeechSynthesisVoice | null,
) {
const { plainText, marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml);
const isCJK = (lang: string | null) => {
const cjkLangs = ['zh', 'ja', 'kr'];
if (lang && cjkLangs.some((cjk) => lang.startsWith(cjk))) return true;
return /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(plainText);
};
if (!isCJK(lang)) {
yield* speakWithBoundary(ssml, getRate, getPitch, getVoice);
return;
}
const synth = window.speechSynthesis;
for (const mark of marks) {
const utterance = new SpeechSynthesisUtterance(mark.text);
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
if (lang) {
utterance.lang = lang;
}
yield {
type: 'boundary',
speaking: true,
name: 'sentence',
mark: mark.name,
} as TTSBoundaryEvent;
const result = await new Promise<TTSBoundaryEvent>((resolve) => {
utterance.onend = () => resolve({ type: 'end', speaking: false });
utterance.onerror = (event) =>
resolve({
type: 'error',
speaking: false,
error: event.error,
});
synth.speak(utterance);
});
yield result;
if (result.type === 'error') {
break;
}
}
}
export class WebSpeechClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: SpeechSynthesisVoice | null = null;
#voices: SpeechSynthesisVoice[] = [];
#synth = window.speechSynthesis;
available = true;
async init() {
if (!this.#synth) {
this.available = false;
return this.available;
}
await new Promise<void>((resolve) => {
const populateVoices = () => {
this.#voices = this.#synth.getVoices();
// console.log('Voices', this.#voices);
if (this.#voices.length > 0) {
resolve();
}
};
if (this.#synth.getVoices().length > 0) {
populateVoices();
} else if (this.#synth.onvoiceschanged !== undefined) {
this.#synth.onvoiceschanged = populateVoices;
} else {
console.warn('Voiceschanged event not supported.');
resolve();
}
});
return this.available;
}
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
const lang = parseSSMLLang(ssml) || 'en';
if (!this.#voice) {
const voices = await this.getVoices(lang);
const voiceId = voices[0]?.id ?? '';
this.#voice = this.#voices.find((v) => v.voiceURI === voiceId) || null;
}
for await (const ev of speakWithMarks(
ssml,
() => this.#rate,
() => this.#pitch,
() => this.#voice,
)) {
if (ev.type === 'boundary') {
yield {
code: 'boundary',
mark: ev.mark ?? '',
message: `${ev.name ?? 'Unknown'} ${ev.charIndex ?? 0}/${ev.charLength ?? 0}`,
} as TTSMessageEvent;
} else if (ev.type === 'error') {
yield { code: 'error', message: ev.error ?? 'Unknown error' } as TTSMessageEvent;
} else if (ev.type === 'end') {
yield { code: 'end', message: 'Speech finished' } as TTSMessageEvent;
}
}
}
async pause() {
this.#synth.pause();
}
async resume() {
this.#synth.resume();
}
async stop() {
this.#synth.cancel();
}
async setRate(rate: number) {
// The Web Speech API uses utterance.rate in [0.1 .. 10],
this.#rate = rate;
}
async setPitch(pitch: number) {
// The Web Speech API uses pitch in [0 .. 2].
this.#pitch = pitch;
}
async setVoice(voiceId: string) {
const selectedVoice = this.#voices.find((v) => v.voiceURI === voiceId);
if (selectedVoice) {
this.#voice = selectedVoice;
}
}
async getAllVoices(): Promise<TTSVoice[]> {
const voices = this.#voices.map((voice) => {
return {
id: voice.voiceURI,
name: voice.name,
lang: voice.lang,
disabled: !this.available,
} as TTSVoice;
});
return voices;
}
async getVoices(lang: string) {
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
const isValidVoice = (id: string) => {
return !id.includes('com.apple') || id.includes('com.apple.voice.compact');
};
const isNotBlacklisted = (voice: SpeechSynthesisVoice) => {
return BLACKLISTED_VOICES.some((name) => voice.name.includes(name)) === false;
};
const filteredVoices = this.#voices
.filter((voice) => voice.lang.startsWith(locale))
.filter((voice) => isValidVoice(voice.voiceURI || ''))
.filter(isNotBlacklisted);
const voices = filteredVoices.map((voice) => {
return { id: voice.voiceURI, name: voice.name, lang: voice.lang } as TTSVoice;
});
voices.forEach((voice) => {
voice.disabled = !this.available;
});
return voices;
}
getGranularities(): TTSGranularity[] {
// currently only support sentence boundary and disable word boundary as changing voice
// in the middle of speech is not possible for different granularities
return ['sentence'];
}
getVoiceId(): string {
return this.#voice?.voiceURI ?? '';
}
}

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