Merge pull request #62 from richardr1126/version1.0.0
Some checks failed
Build and Publish Docker Image / build (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build (push) Has been cancelled
Merge v1.0.0 to main branch
This commit is contained in:
commit
d7a94a6cb0
83 changed files with 7157 additions and 3021 deletions
8
.github/workflows/playwright.yml
vendored
8
.github/workflows/playwright.yml
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [ main, master, version1.0.0 ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, master ]
|
branches: [ main, master ]
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -16,12 +16,14 @@ jobs:
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 9
|
||||||
- name: Install Deps (FFmpeg is install through Playwright)
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libreoffice-writer
|
sudo apt-get install -y libreoffice-writer ffmpeg
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Verify ffprobe
|
||||||
|
run: ffprobe -version
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ COPY . .
|
||||||
|
|
||||||
# Build the Next.js application
|
# Build the Next.js application
|
||||||
RUN pnpm exec next telemetry disable
|
RUN pnpm exec next telemetry disable
|
||||||
RUN pnpm run build
|
RUN pnpm build
|
||||||
|
|
||||||
# Expose the port the app runs on
|
# Expose the port the app runs on
|
||||||
EXPOSE 3003
|
EXPOSE 3003
|
||||||
|
|
|
||||||
189
README.md
189
README.md
|
|
@ -7,28 +7,69 @@
|
||||||
|
|
||||||
[](../../discussions)
|
[](../../discussions)
|
||||||
|
|
||||||
# OpenReader WebUI 📄🔊
|
# 📄🔊 OpenReader WebUI
|
||||||
|
|
||||||
OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering a TTS read along experience with narration for EPUB, PDF, TXT, MD, and DOCX documents. It supports multiple TTS providers including OpenAI, Deepinfra, and custom OpenAI-compatible endpoints like [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI)
|
OpenReader WebUI is an open source text to speech document reader web app built using Next.js, offering a TTS read along experience with narration for EPUB, PDF, TXT, MD, and DOCX documents. It supports multiple TTS providers including OpenAI, Deepinfra, and custom OpenAI-compatible endpoints like [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI)
|
||||||
|
|
||||||
- 🎯 **Multi-Provider TTS Support**:
|
- 🧠 **(New) Smart Sentence-Aware Narration**: EPUB and PDF playback use shared NLP (compromise) and smart sentence continuation to merge sentences that span pages/chapters for smoother TTS trying to prevent hard cuts at page breaks
|
||||||
- **OpenAI**: tts-1, tts-1-hd, gpt-4o-mini-tts models with voices (alloy, echo, fable, onyx, nova, shimmer)
|
- 🎧 **(New) Reliable Audiobook Export**: Create and export audiobooks from PDF and EPUB files **(in m4b or mp3 format using ffmpeg)** with resumable, chapter/page-based export and per-chapter regeneration
|
||||||
|
- 🎯 **(New) Multi-Provider TTS Support**:
|
||||||
- **Deepinfra**: Kokoro-82M, Orpheus-3B, Sesame-1B models with extensive voice libraries
|
- **Deepinfra**: Kokoro-82M, Orpheus-3B, Sesame-1B models with extensive voice libraries
|
||||||
- **Custom OpenAI-Compatible**: Any OpenAI-compatible endpoint with custom voice sets
|
- **OpenAI API ($$)**: tts-1, tts-1-hd, gpt-4o-mini-tts models
|
||||||
- 💾 **Local-First Architecture**: Uses IndexedDB browser storage for documents
|
- **Kokoro-FastAPI**: Self-hosted OpenAI-compatible TTS API server supporting Kokoro-82M and multi-voice combinations (like `af_heart+bf_emma`)
|
||||||
- 🛜 **Optional Server-side documents**: Manually upload documents to the next backend for all users to download
|
- **Orpheus-FastAPI**: Self-hosted OpenAI-compatible TTS API server supporting Orpheus-3B
|
||||||
- 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates
|
- And other Custom OpenAI-compatible endpoints with a `/v1/audio/voices` endpoint
|
||||||
- 📄 **Document formats**: EPUB, PDF, TXT, MD, DOCX (with libreoffice installed)
|
- 🚀 **(New) Optimized TTS Pipeline**: Next.js TTS backend with in-memory LRU audio cache, ETag-aware responses, and in-flight request de-duplication for faster repeat playback
|
||||||
- 🎧 **Audiobook Creation**: Create and export audiobooks from PDF and ePub files **(in m4b format with ffmpeg and aac TTS output)**
|
- 💾 **Local-First Architecture**: IndexedDB browser storage for documents and settings (now using Dexie.js)
|
||||||
|
- 🛜 **Optional Server-side documents**: Manually upload documents to the Next.js backend (and Docker `docstore`) for all users to download
|
||||||
|
- 📖 **Read Along Experience**: Follow along with real-time highlighted text as the TTS narrates PDF files, using an overlay-based highlighter, per-sentence navigation, and skip controls
|
||||||
|
- 📄 **Document formats**: EPUB, PDF, TXT, MD, DOCX (with libreoffice installed, plus hardened DOCX→PDF conversion for better reliability)
|
||||||
- 🎨 **Customizable Experience**:
|
- 🎨 **Customizable Experience**:
|
||||||
- 🔑 Select TTS provider (OpenAI, Deepinfra, or Custom OpenAI-compatible)
|
- 🔑 Select TTS provider (OpenAI, Deepinfra, or Custom OpenAI-compatible)
|
||||||
- 🔐 Set TTS API base URL and optional API key
|
- 🔐 Set TTS API base URL and optional API key
|
||||||
- 🎨 Multiple app theme options
|
- 🎨 Multiple app theme options
|
||||||
- And more...
|
- And more...
|
||||||
|
|
||||||
### 🛠️ Work in progress
|
<details>
|
||||||
- [ ] **Native .docx support** (currently requires libreoffice)
|
<summary>
|
||||||
- [ ] **Accessibility Improvements**
|
|
||||||
|
### 🆕 What's New in v1.0.0
|
||||||
|
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
- 🧠 **Smart sentence continuation**
|
||||||
|
- Improved NLP handling of complex structures and quoted dialogue provides more natural sentence boundaries and a smoother audio-text flow.
|
||||||
|
- EPUB and PDF playback now use smarter sentence splitting and continuation metadata so sentences that cross page/chapter boundaries are merged before hitting the TTS API.
|
||||||
|
- This yields more natural narration and fewer awkward pauses when a sentence spans multiple pages or EPUB spine items.
|
||||||
|
- 📄 **Modernized PDF text highlighting pipeline**
|
||||||
|
- Real-time PDF text highlighting is now offloaded to a dedicated Web Worker so scrolling and playback controls remain responsive during narration.
|
||||||
|
- A new overlay-based highlighting system draws independent highlight layers on top of the PDF, avoiding interference with the underlying text layer.
|
||||||
|
- Upgraded fuzzy matching with Dice-based similarity improves the accuracy of mapping spoken words to on-screen text.
|
||||||
|
- A new per-device setting lets you enable or disable real-time PDF highlighting during playback for a more tailored reading experience.
|
||||||
|
- 🎧 **Chapter/page-based audiobook export with resume & regeneration**
|
||||||
|
- Per-chapter/per-page generation to disk with persistent `bookId`
|
||||||
|
- Resumable generation (can cancel and continue later)
|
||||||
|
- Per-chapter regeneration & deletion
|
||||||
|
- Final combined **M4B** or **MP3** download with embedded chapter metadata.
|
||||||
|
- 💾 **Dexie-backed local storage & sync**
|
||||||
|
- All document types (PDF, EPUB, TXT/MD-as-HTML) and config are stored via a unified Dexie layer on top of IndexedDB.
|
||||||
|
- Document lists use live Dexie queries (no manual refresh needed), and server sync now correctly includes text/markdown documents as part of the library backup.
|
||||||
|
- 🗣️ **Kokoro multi-voice selection & utilities**
|
||||||
|
- Kokoro models now support multi-voice combination, with provider-aware limits and helpers (not supported on OpenAI or Deepinfra)
|
||||||
|
- ⚡ **Faster, more efficient TTS backend proxy**
|
||||||
|
- In-memory **LRU caching** for audio responses with configurable size/TTL
|
||||||
|
- **ETag** support (`304` on cache hits) + `X-Cache` headers (`HIT` / `MISS` / `INFLIGHT`)
|
||||||
|
- 📄 **More robust DOCX → PDF conversion**
|
||||||
|
- DOCX conversion now uses isolated per-job LibreOffice profiles and temp directories, polls for a stable output file size, and aggressively cleans up temp files.
|
||||||
|
- This reduces cross-job interference and flakiness when converting multiple DOCX files in parallel.
|
||||||
|
- ♿ **Accessibility & layout improvements**
|
||||||
|
- Dialogs and folder toggles expose proper roles and ARIA attributes.
|
||||||
|
- PDF/EPUB/HTML readers use a full-height app shell with a sticky bottom TTS bar, improved scrollbars, and refined focus styles.
|
||||||
|
- ✅ **End-to-end Playwright test suite with TTS mocks**
|
||||||
|
- Deterministic TTS responses in tests via a reusable Playwright route mock.
|
||||||
|
- Coverage for accessibility, upload, navigation, folder management, deletion flows, audiobook generation/export and playback across all document types.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## 🐳 Docker Quick Start
|
## 🐳 Docker Quick Start
|
||||||
|
|
||||||
|
|
@ -36,9 +77,12 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
|
||||||
- Recent version of Docker installed on your machine
|
- Recent version of Docker installed on your machine
|
||||||
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible
|
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible
|
||||||
|
|
||||||
|
> **Note:** If you have good hardware, you can run [Kokoro-FastAPI with Docker locally](#🗣️-local-kokoro-fastapi-quick-start-cpu-or-gpu) (see below).
|
||||||
|
|
||||||
### 1. 🐳 Start the Docker container:
|
### 1. 🐳 Start the Docker container:
|
||||||
```bash
|
```bash
|
||||||
docker run --name openreader-webui \
|
docker run --name openreader-webui \
|
||||||
|
--restart unless-stopped \
|
||||||
-p 3003:3003 \
|
-p 3003:3003 \
|
||||||
-v openreader_docstore:/app/docstore \
|
-v openreader_docstore:/app/docstore \
|
||||||
ghcr.io/richardr1126/openreader-webui:latest
|
ghcr.io/richardr1126/openreader-webui:latest
|
||||||
|
|
@ -47,6 +91,7 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
|
||||||
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
|
(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices
|
||||||
```bash
|
```bash
|
||||||
docker run --name openreader-webui \
|
docker run --name openreader-webui \
|
||||||
|
--restart unless-stopped \
|
||||||
-e API_KEY=none \
|
-e API_KEY=none \
|
||||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||||
-p 3003:3003 \
|
-p 3003:3003 \
|
||||||
|
|
@ -72,48 +117,82 @@ docker rm openreader-webui && \
|
||||||
docker pull ghcr.io/richardr1126/openreader-webui:latest
|
docker pull ghcr.io/richardr1126/openreader-webui:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### (Alternate) 🐳 Configuration with Docker Compose and Kokoro-FastAPI
|
### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU)
|
||||||
|
|
||||||
A complete example docker-compose file with Kokoro-FastAPI and OpenReader WebUI is available in [`docs/examples/docker-compose.yml`](docs/examples/docker-compose.yml). You can download and use it:
|
You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version).
|
||||||
|
|
||||||
|
> **Note:** When using these, set the `API_BASE` env var to `http://host.docker.internal:8880/v1` or `http://kokoro-tts:8880/v1`.
|
||||||
|
> You can also use the example `docker-compose.yml` in `examples/docker-compose.yml` if you prefer Docker Compose.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
|
||||||
|
**Docker CPU**
|
||||||
|
|
||||||
|
</summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download example docker-compose.yml
|
docker run -d \
|
||||||
curl --create-dirs -L -o openreader-compose/docker-compose.yml https://raw.githubusercontent.com/richardr1126/OpenReader-WebUI/main/docs/examples/docker-compose.yml
|
--name kokoro-tts \
|
||||||
|
--restart unless-stopped \
|
||||||
cd openreader-compose
|
-p 8880:8880 \
|
||||||
docker compose up -d
|
-e ONNX_NUM_THREADS=8 \
|
||||||
|
-e ONNX_INTER_OP_THREADS=4 \
|
||||||
|
-e ONNX_EXECUTION_MODE=parallel \
|
||||||
|
-e ONNX_OPTIMIZATION_LEVEL=all \
|
||||||
|
-e ONNX_MEMORY_PATTERN=true \
|
||||||
|
-e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \
|
||||||
|
-e API_LOG_LEVEL=DEBUG \
|
||||||
|
ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
||||||
```
|
```
|
||||||
|
|
||||||
Or add OpenReader WebUI to your existing `docker-compose.yml`:
|
</details>
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
openreader-webui:
|
|
||||||
container_name: openreader-webui
|
|
||||||
image: ghcr.io/richardr1126/openreader-webui:latest
|
|
||||||
environment:
|
|
||||||
- API_BASE=http://host.docker.internal:8880/v1
|
|
||||||
ports:
|
|
||||||
- "3003:3003"
|
|
||||||
volumes:
|
|
||||||
- docstore:/app/docstore
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
<details>
|
||||||
docstore:
|
<summary>
|
||||||
|
|
||||||
|
**Docker GPU**
|
||||||
|
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name kokoro-tts \
|
||||||
|
--gpus all \
|
||||||
|
--user 1001:1001 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8880:8880 \
|
||||||
|
-e USE_GPU=true \
|
||||||
|
-e PYTHONUNBUFFERED=1 \
|
||||||
|
-e API_LOG_LEVEL=DEBUG \
|
||||||
|
ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dev Installation
|
</details>
|
||||||
|
|
||||||
|
> **Note:**
|
||||||
|
> - These commands are for running the Kokoro TTS API server only. For issues or support, see the [Kokoro-FastAPI repository](https://github.com/remsky/Kokoro-FastAPI).
|
||||||
|
> - The GPU version requires NVIDIA Docker support and works best with NVIDIA GPUs. The CPU version works best on Apple Silicon or modern x86 CPUs.
|
||||||
|
> - Adjust environment variables as needed for your hardware and use case.
|
||||||
|
|
||||||
|
## Local Development Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Node.js & npm or pnpm (recommended: use [nvm](https://github.com/nvm-sh/nvm) for Node.js)
|
- Node.js (recommended: use [nvm](https://github.com/nvm-sh/nvm))
|
||||||
|
- pnpm (recommended) or npm
|
||||||
|
```bash
|
||||||
|
npm install -g pnpm
|
||||||
|
```
|
||||||
|
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible
|
||||||
Optionally required for different features:
|
Optionally required for different features:
|
||||||
- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only)
|
- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only)
|
||||||
- On Linux: `sudo apt install ffmpeg`
|
```bash
|
||||||
- On MacOS: `brew install ffmpeg`
|
brew install ffmpeg
|
||||||
|
```
|
||||||
- [libreoffice](https://www.libreoffice.org) (required for DOCX files)
|
- [libreoffice](https://www.libreoffice.org) (required for DOCX files)
|
||||||
- On Linux: `sudo apt install libreoffice`
|
```bash
|
||||||
- On MacOS: `brew install libreoffice`
|
brew install libreoffice
|
||||||
|
```
|
||||||
### Steps
|
### Steps
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
|
|
@ -126,12 +205,7 @@ Optionally required for different features:
|
||||||
|
|
||||||
With pnpm (recommended):
|
With pnpm (recommended):
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm i # or npm i
|
||||||
```
|
|
||||||
|
|
||||||
Or with npm:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Configure the environment:
|
3. Configure the environment:
|
||||||
|
|
@ -145,26 +219,15 @@ Optionally required for different features:
|
||||||
|
|
||||||
With pnpm (recommended):
|
With pnpm (recommended):
|
||||||
```bash
|
```bash
|
||||||
pnpm dev
|
pnpm dev # or npm run dev
|
||||||
```
|
|
||||||
|
|
||||||
Or with npm:
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
or build and run the production server:
|
or build and run the production server:
|
||||||
|
|
||||||
With pnpm:
|
With pnpm:
|
||||||
```bash
|
```bash
|
||||||
pnpm build
|
pnpm build # or npm run build
|
||||||
pnpm start
|
pnpm start # or npm start
|
||||||
```
|
|
||||||
|
|
||||||
Or with npm:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
npm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit [http://localhost:3003](http://localhost:3003) to run the app.
|
Visit [http://localhost:3003](http://localhost:3003) to run the app.
|
||||||
|
|
@ -201,7 +264,7 @@ This project would not be possible without standing on the shoulders of these gi
|
||||||
|
|
||||||
- **Framework:** Next.js (React)
|
- **Framework:** Next.js (React)
|
||||||
- **Containerization:** Docker
|
- **Containerization:** Docker
|
||||||
- **Storage:** IndexedDB (in browser db store)
|
- **Storage:** Dexie + IndexedDB (in-browser local database)
|
||||||
- **PDF:**
|
- **PDF:**
|
||||||
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
|
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
|
||||||
- [pdf.js](https://mozilla.github.io/pdf.js/)
|
- [pdf.js](https://mozilla.github.io/pdf.js/)
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
# OpenReader Issue Triage and Mapping
|
|
||||||
|
|
||||||
Repository: https://github.com/richardr1126/OpenReader-WebUI/issues
|
|
||||||
Reviewed via gh at 2025-11-10.
|
|
||||||
|
|
||||||
Summary of open items included:
|
|
||||||
- #59 Feature: Chapter-Based MP3 Export
|
|
||||||
- #48 Bug: Failed to export after complete render with Kokoro
|
|
||||||
- #47 Feature: Combine voices (Kokoro “plus” syntax)
|
|
||||||
- #44 Bug: Dialog not chunked together
|
|
||||||
- #40 Bug: PDF left/right extraction margins not working
|
|
||||||
|
|
||||||
Global guardrails
|
|
||||||
- Streaming-first playback (replace Howler with HTMLAudioElement/MSE)
|
|
||||||
- Dexie DB replacing vanilla IndexedDB
|
|
||||||
- Single engine cut-over (no dual engines)
|
|
||||||
- Keep audiobook (m4b) and server-side sync
|
|
||||||
|
|
||||||
Issue #59 — Chapter-Based MP3 Export
|
|
||||||
Type: Feature
|
|
||||||
Hypothesis / intent:
|
|
||||||
- Users want one-mp3-per-chapter output (besides full-book m4b).
|
|
||||||
Ideas:
|
|
||||||
- Add chapterized MP3 pipeline that chunks by adapter “chapter” units.
|
|
||||||
- Provide ZIP export for many MP3 files (streamed).
|
|
||||||
API design:
|
|
||||||
- POST /api/audio/convert?mode=chapters&format=mp3 -> returns stream of a ZIP.
|
|
||||||
Logging to add:
|
|
||||||
- Chapter boundaries, byte sizes, cumulative progress in [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1).
|
|
||||||
Acceptance:
|
|
||||||
- A multi-chapter EPUB produces N mp3 parts named “NN - Chapter Title.mp3”; total duration ~ sum of parts; ZIP streamed without timeouts in Docker.
|
|
||||||
|
|
||||||
Issue #48 — Failed export after complete render with Kokoro
|
|
||||||
Type: Bug (large-book export)
|
|
||||||
Observations:
|
|
||||||
- UI reaches 100% then resets, no download.
|
|
||||||
- Docker shows “fin … undefined”, likely final transfer problem (not TTS).
|
|
||||||
Likely root causes:
|
|
||||||
- Final m4b delivery uses single huge arrayBuffer; browser memory/timeout.
|
|
||||||
- Missing Content-Disposition/Range; no resumable download.
|
|
||||||
- Temp-file lifecycle cleanup racing with response.
|
|
||||||
Ideas for remediation:
|
|
||||||
- Serve final artifact as file on disk with streaming and Range:
|
|
||||||
- New endpoint: GET /api/audio/convert/download?bookId=… that streams file with Accept-Ranges.
|
|
||||||
- UI performs streamed download; no arrayBuffer buffering.
|
|
||||||
- Optionally support S3-compatible offload in future.
|
|
||||||
Touched modules:
|
|
||||||
- [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1)
|
|
||||||
Instrumentation to add:
|
|
||||||
- Book ID, file size on disk, stream chunk counts, and client-abort detection.
|
|
||||||
- Add explicit log/error surfaces at:
|
|
||||||
- [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1)
|
|
||||||
Acceptance:
|
|
||||||
- 1–2 GB m4b exports download in Docker without UI reset; bookId persists until deletion; status 206 Range works.
|
|
||||||
|
|
||||||
Issue #47 — Kokoro combined voices (“+” syntax)
|
|
||||||
Type: Feature
|
|
||||||
Intent:
|
|
||||||
- Support voice strings like “bf_emma+af_heart” when provider is Kokoro/FastAPI or DeepInfra Kokoro.
|
|
||||||
Ideas for implementation:
|
|
||||||
- Allow free-form voice string entry and pass-through if not in known set.
|
|
||||||
- Validate known providers; if “+” present, skip voice validation list.
|
|
||||||
|
|
||||||
Logging:
|
|
||||||
- Emit provider, model, raw voice string in request (no PII).
|
|
||||||
Tests:
|
|
||||||
- “a+b” voices produce audible combined output; works via DeepInfra pass-through and Kokoro-FastAPI; voice dropdown offers “Custom voice…” input.
|
|
||||||
|
|
||||||
Issue #44 — Dialog not being chunked together
|
|
||||||
Type: Bug (NLP splitting)
|
|
||||||
Observations:
|
|
||||||
- Dialog lines split mid-quote; needs grouping.
|
|
||||||
Proposed improvements:
|
|
||||||
- Extend [splitIntoSentences()](src/utils/nlp.ts:34) to apply quote-aware grouping.
|
|
||||||
- When a sentence begins with an opening quote and the next ends with a closing quote, join them before MAX_BLOCK_LENGTH checks.
|
|
||||||
Ideas for remediation:
|
|
||||||
- Provide composable splitter strategy wrapping existing utility.
|
|
||||||
- Add “dialog-preserve” flag toggled in settings.
|
|
||||||
Logging to add:
|
|
||||||
- Count of quote-joined sentences; per-page example of before/after lengths.
|
|
||||||
Acceptance:
|
|
||||||
- Quoted dialog flows as single units unless exceeding MAX_BLOCK_LENGTH by > X%; regression-safe for non-dialog text.
|
|
||||||
|
|
||||||
Issue #40 — PDF left/right extraction margins not working
|
|
||||||
Type: Bug (PDF extraction)
|
|
||||||
Observations:
|
|
||||||
- Current filter uses transform[4]/[5] with width heuristics. Edge cases: missing width, skew, page scale differences.
|
|
||||||
Likely causes:
|
|
||||||
- Some pdf.js TextItem miss width; horizontal margins computed but not applied due to scale mismatch.
|
|
||||||
- Defaults in [src/contexts/ConfigContext.tsx](src/contexts/ConfigContext.tsx:216) set left/right to '0.0' on first run; UX confusion.
|
|
||||||
Remediation:
|
|
||||||
- Compute glyph bbox width when width absent using transform matrix.
|
|
||||||
- Normalize x to [0..1] by dividing by pageWidth; compare to margins reliably.
|
|
||||||
- Add visual debug overlay (dev mode) to draw margin boxes while extracting.
|
|
||||||
Code touchpoints:
|
|
||||||
- [src/utils/pdf.ts](src/utils/pdf.ts:60) extractTextFromPDF(): margin math and width fallback.
|
|
||||||
Diagnostics to add:
|
|
||||||
- Per-page: kept/filtered counts, extremes of x positions, computed margins.
|
|
||||||
Acceptance:
|
|
||||||
- Test PDFs show left/right trimming correctly; e2e highlight still robust.
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "openreader-webui",
|
"name": "openreader-webui",
|
||||||
"version": "0.4.0",
|
"version": "v1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 3003",
|
"dev": "next dev --turbopack -p 3003",
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
"cmpstr": "^3.0.4",
|
"cmpstr": "^3.0.4",
|
||||||
"compromise": "^14.14.4",
|
"compromise": "^14.14.4",
|
||||||
"core-js": "^3.46.0",
|
"core-js": "^3.46.0",
|
||||||
|
"dexie": "^4.2.1",
|
||||||
|
"dexie-react-hooks": "^4.2.0",
|
||||||
"epubjs": "^0.3.93",
|
"epubjs": "^0.3.93",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"lru-cache": "^11.2.2",
|
"lru-cache": "^11.2.2",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
|
||||||
* Read environment variables from file.
|
|
||||||
* https://github.com/motdotla/dotenv
|
|
||||||
*/
|
|
||||||
// import dotenv from 'dotenv';
|
|
||||||
// import path from 'path';
|
|
||||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
|
|
@ -15,14 +7,11 @@ export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
timeout: 30 * 1000,
|
timeout: 30 * 1000,
|
||||||
outputDir: './tests/results',
|
outputDir: './tests/results',
|
||||||
/* Run tests in files in parallel */
|
// fullyParallel: false,
|
||||||
fullyParallel: true,
|
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
// workers: '50%',
|
||||||
workers: undefined,
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ importers:
|
||||||
core-js:
|
core-js:
|
||||||
specifier: ^3.46.0
|
specifier: ^3.46.0
|
||||||
version: 3.46.0
|
version: 3.46.0
|
||||||
|
dexie:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
|
dexie-react-hooks:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0(@types/react@19.2.2)(dexie@4.2.1)(react@19.2.0)
|
||||||
epubjs:
|
epubjs:
|
||||||
specifier: ^0.3.93
|
specifier: ^0.3.93
|
||||||
version: 0.3.93
|
version: 0.3.93
|
||||||
|
|
@ -1093,6 +1099,16 @@ packages:
|
||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||||
|
|
||||||
|
dexie-react-hooks@4.2.0:
|
||||||
|
resolution: {integrity: sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16'
|
||||||
|
dexie: '>=4.2.0-alpha.1 <5.0.0'
|
||||||
|
react: '>=16'
|
||||||
|
|
||||||
|
dexie@4.2.1:
|
||||||
|
resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==}
|
||||||
|
|
||||||
didyoumean@1.2.2:
|
didyoumean@1.2.2:
|
||||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||||
|
|
||||||
|
|
@ -3710,6 +3726,14 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
dexie-react-hooks@4.2.0(@types/react@19.2.2)(dexie@4.2.1)(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
dexie: 4.2.1
|
||||||
|
react: 19.2.0
|
||||||
|
|
||||||
|
dexie@4.2.1: {}
|
||||||
|
|
||||||
didyoumean@1.2.2: {}
|
didyoumean@1.2.2: {}
|
||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
|
||||||
134
src/app/api/audio/convert/chapter/route.ts
Normal file
134
src/app/api/audio/convert/chapter/route.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createReadStream, existsSync } from 'fs';
|
||||||
|
import { readFile, unlink } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||||
|
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
|
||||||
|
|
||||||
|
if (!bookId || !chapterIndexStr) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing bookId or chapterIndex parameter' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterIndex = parseInt(chapterIndexStr);
|
||||||
|
if (isNaN(chapterIndex)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid chapterIndex parameter' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docstoreDir = join(process.cwd(), 'docstore');
|
||||||
|
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||||
|
|
||||||
|
// Read metadata to get format
|
||||||
|
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
||||||
|
if (!existsSync(metadataPath)) {
|
||||||
|
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'));
|
||||||
|
const format = metadata.format || 'm4b';
|
||||||
|
const chapterPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
|
||||||
|
|
||||||
|
if (!existsSync(chapterPath)) {
|
||||||
|
return NextResponse.json({ error: 'Chapter file not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the chapter file
|
||||||
|
const stream = createReadStream(chapterPath);
|
||||||
|
|
||||||
|
const readableWebStream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||||
|
const sanitizedTitle = metadata.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||||
|
|
||||||
|
return new NextResponse(readableWebStream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Content-Disposition': `attachment; filename="${sanitizedTitle}.${format}"`,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading chapter:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to download chapter' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||||
|
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
|
||||||
|
|
||||||
|
if (!bookId || !chapterIndexStr) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing bookId or chapterIndex parameter' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterIndex = parseInt(chapterIndexStr, 10);
|
||||||
|
if (isNaN(chapterIndex)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid chapterIndex parameter' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docstoreDir = join(process.cwd(), 'docstore');
|
||||||
|
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||||
|
|
||||||
|
// Read metadata to get format (if present)
|
||||||
|
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
||||||
|
|
||||||
|
// Delete the chapter audio file (try both formats just in case)
|
||||||
|
const chapterPathM4b = join(intermediateDir, `${chapterIndex}-chapter.m4b`);
|
||||||
|
const chapterPathMp3 = join(intermediateDir, `${chapterIndex}-chapter.mp3`);
|
||||||
|
if (existsSync(chapterPathM4b)) await unlink(chapterPathM4b).catch(() => {});
|
||||||
|
if (existsSync(chapterPathMp3)) await unlink(chapterPathMp3).catch(() => {});
|
||||||
|
|
||||||
|
// Delete metadata if present
|
||||||
|
if (existsSync(metadataPath)) {
|
||||||
|
await unlink(metadataPath).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate any combined "complete" files
|
||||||
|
const completeM4b = join(intermediateDir, `complete.m4b`);
|
||||||
|
const completeMp3 = join(intermediateDir, `complete.mp3`);
|
||||||
|
if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {});
|
||||||
|
if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting chapter:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete chapter' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/app/api/audio/convert/chapters/route.ts
Normal file
98
src/app/api/audio/convert/chapters/route.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readdir, readFile, rm } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||||
|
if (!bookId) {
|
||||||
|
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const docstoreDir = join(process.cwd(), 'docstore');
|
||||||
|
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||||
|
|
||||||
|
if (!existsSync(intermediateDir)) {
|
||||||
|
return NextResponse.json({ chapters: [], exists: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all chapter metadata
|
||||||
|
const files = await readdir(intermediateDir);
|
||||||
|
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
|
||||||
|
const chapters: Array<{
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
duration?: number;
|
||||||
|
status: 'completed' | 'error';
|
||||||
|
bookId: string;
|
||||||
|
format?: 'mp3' | 'm4b';
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const metaFile of metaFiles) {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
|
||||||
|
chapters.push({
|
||||||
|
index: meta.index,
|
||||||
|
title: meta.title,
|
||||||
|
duration: meta.duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format: meta.format || 'm4b'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading metadata file ${metaFile}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chapters by index
|
||||||
|
chapters.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
// Check if complete audiobook exists (either format)
|
||||||
|
const format = chapters[0]?.format || 'm4b';
|
||||||
|
const completePath = join(intermediateDir, `complete.${format}`);
|
||||||
|
const hasComplete = existsSync(completePath);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
chapters,
|
||||||
|
exists: true,
|
||||||
|
hasComplete,
|
||||||
|
bookId
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching chapters:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch chapters' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||||
|
if (!bookId) {
|
||||||
|
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const docstoreDir = join(process.cwd(), 'docstore');
|
||||||
|
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||||
|
|
||||||
|
// If directory doesn't exist, consider it already reset
|
||||||
|
if (!existsSync(intermediateDir)) {
|
||||||
|
return NextResponse.json({ success: true, existed: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively delete the entire audiobook directory
|
||||||
|
await rm(intermediateDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, existed: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting audiobook:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to reset audiobook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { writeFile, readFile, mkdir, unlink, rmdir, readdir } from 'fs/promises';
|
import { writeFile, readFile, mkdir, unlink, readdir } from 'fs/promises';
|
||||||
import { existsSync, createReadStream } from 'fs';
|
import { existsSync, createReadStream } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
@ -9,9 +9,11 @@ interface ConversionRequest {
|
||||||
chapterTitle: string;
|
chapterTitle: string;
|
||||||
buffer: number[];
|
buffer: number[];
|
||||||
bookId?: string;
|
bookId?: string;
|
||||||
|
format?: 'mp3' | 'm4b';
|
||||||
|
chapterIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAudioDuration(filePath: string): Promise<number> {
|
async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ffprobe = spawn('ffprobe', [
|
const ffprobe = spawn('ffprobe', [
|
||||||
'-i', filePath,
|
'-i', filePath,
|
||||||
|
|
@ -21,11 +23,38 @@ async function getAudioDuration(filePath: string): Promise<number> {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let output = '';
|
let output = '';
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
try {
|
||||||
|
ffprobe.kill('SIGKILL');
|
||||||
|
} catch {}
|
||||||
|
reject(new Error('ABORTED'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
ffprobe.stdout.on('data', (data) => {
|
ffprobe.stdout.on('data', (data) => {
|
||||||
output += data.toString();
|
output += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
ffprobe.on('close', (code) => {
|
ffprobe.on('close', (code) => {
|
||||||
|
if (finished) return;
|
||||||
|
cleanup();
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
const duration = parseFloat(output.trim());
|
const duration = parseFloat(output.trim());
|
||||||
resolve(duration);
|
resolve(duration);
|
||||||
|
|
@ -35,20 +64,44 @@ async function getAudioDuration(filePath: string): Promise<number> {
|
||||||
});
|
});
|
||||||
|
|
||||||
ffprobe.on('error', (err) => {
|
ffprobe.on('error', (err) => {
|
||||||
|
if (finished) return;
|
||||||
|
cleanup();
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runFFmpeg(args: string[]): Promise<void> {
|
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const ffmpeg = spawn('ffmpeg', args);
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
try {
|
||||||
|
ffmpeg.kill('SIGKILL');
|
||||||
|
} catch {}
|
||||||
|
reject(new Error('ABORTED'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
ffmpeg.stderr.on('data', (data) => {
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
console.error(`ffmpeg stderr: ${data}`);
|
console.error(`ffmpeg stderr: ${data}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
ffmpeg.on('close', (code) => {
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -57,6 +110,9 @@ async function runFFmpeg(args: string[]): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
ffmpeg.on('error', (err) => {
|
ffmpeg.on('error', (err) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -66,51 +122,84 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Parse the request body
|
// Parse the request body
|
||||||
const data: ConversionRequest = await request.json();
|
const data: ConversionRequest = await request.json();
|
||||||
|
const format = data.format || 'm4b';
|
||||||
|
|
||||||
// Create temp directory if it doesn't exist
|
// Use docstore directory
|
||||||
const tempDir = join(process.cwd(), 'temp');
|
const docstoreDir = join(process.cwd(), 'docstore');
|
||||||
if (!existsSync(tempDir)) {
|
if (!existsSync(docstoreDir)) {
|
||||||
await mkdir(tempDir);
|
await mkdir(docstoreDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate or use existing book ID
|
// Generate or use existing book ID
|
||||||
const bookId = data.bookId || randomUUID();
|
const bookId = data.bookId || randomUUID();
|
||||||
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
|
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||||
|
|
||||||
// Create intermediate directory
|
// Create intermediate directory
|
||||||
if (!existsSync(intermediateDir)) {
|
if (!existsSync(intermediateDir)) {
|
||||||
await mkdir(intermediateDir);
|
await mkdir(intermediateDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count existing files to determine chapter index
|
// Use provided chapter index or find the next available index robustly (handles gaps)
|
||||||
const files = await readdir(intermediateDir);
|
let chapterIndex: number;
|
||||||
const wavFiles = files.filter(f => f.endsWith('.wav'));
|
if (data.chapterIndex !== undefined) {
|
||||||
const chapterIndex = wavFiles.length;
|
chapterIndex = data.chapterIndex;
|
||||||
|
} else {
|
||||||
|
const files = await readdir(intermediateDir);
|
||||||
|
const indices = files
|
||||||
|
.map(f => f.match(/^(\d+)-chapter\.(m4b|mp3)$/))
|
||||||
|
.filter((m): m is RegExpMatchArray => Boolean(m))
|
||||||
|
.map(m => parseInt(m[1], 10))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
// Find smallest non-negative integer not present
|
||||||
|
let next = 0;
|
||||||
|
for (const idx of indices) {
|
||||||
|
if (idx === next) {
|
||||||
|
next++;
|
||||||
|
} else if (idx > next) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chapterIndex = next;
|
||||||
|
}
|
||||||
|
|
||||||
// Write input file
|
// Write input file (MP3 from TTS)
|
||||||
const inputPath = join(intermediateDir, `${chapterIndex}-input.aac`);
|
const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`);
|
||||||
const outputPath = join(intermediateDir, `${chapterIndex}.wav`);
|
const chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
|
||||||
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
||||||
|
|
||||||
// Write the chapter audio to a temp file
|
// Write the chapter audio to a temp file
|
||||||
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
|
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
|
||||||
|
|
||||||
// Convert to WAV from raw aac with consistent format
|
if (format === 'mp3') {
|
||||||
await runFFmpeg([
|
// For MP3, re-encode to ensure proper headers and consistent format
|
||||||
'-i', inputPath,
|
await runFFmpeg([
|
||||||
'-f', 'wav',
|
'-y', // Overwrite output file without asking
|
||||||
'-c:a', 'copy',
|
'-i', inputPath,
|
||||||
'-preset', 'ultrafast',
|
'-c:a', 'libmp3lame',
|
||||||
'-threads', '0',
|
'-b:a', '64k',
|
||||||
outputPath
|
'-metadata', `title=${data.chapterTitle}`,
|
||||||
]);
|
chapterOutputPath
|
||||||
|
], request.signal);
|
||||||
|
} else {
|
||||||
|
// Convert MP3 to M4B container with proper encoding and metadata
|
||||||
|
await runFFmpeg([
|
||||||
|
'-y', // Overwrite output file without asking
|
||||||
|
'-i', inputPath,
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-b:a', '64k',
|
||||||
|
'-metadata', `title=${data.chapterTitle}`,
|
||||||
|
'-f', 'mp4',
|
||||||
|
chapterOutputPath
|
||||||
|
], request.signal);
|
||||||
|
}
|
||||||
|
|
||||||
// Get the duration and save metadata
|
// Get the duration and save metadata
|
||||||
const duration = await getAudioDuration(outputPath);
|
const duration = await getAudioDuration(chapterOutputPath, request.signal);
|
||||||
await writeFile(metadataPath, JSON.stringify({
|
await writeFile(metadataPath, JSON.stringify({
|
||||||
title: data.chapterTitle,
|
title: data.chapterTitle,
|
||||||
duration,
|
duration,
|
||||||
index: chapterIndex
|
index: chapterIndex,
|
||||||
|
format
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Clean up input file
|
// Clean up input file
|
||||||
|
|
@ -123,9 +212,15 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'cancelled' },
|
||||||
|
{ status: 499 }
|
||||||
|
);
|
||||||
|
}
|
||||||
console.error('Error processing audio chapter:', error);
|
console.error('Error processing audio chapter:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to process audio chapter' },
|
{ error: 'Failed to process audio chapter' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -134,15 +229,13 @@ export async function POST(request: NextRequest) {
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||||
|
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempDir = join(process.cwd(), 'temp');
|
const docstoreDir = join(process.cwd(), 'docstore');
|
||||||
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
|
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||||
const outputPath = join(tempDir, `${bookId}.m4b`);
|
|
||||||
const metadataPath = join(tempDir, `${bookId}-metadata.txt`);
|
|
||||||
const listPath = join(tempDir, `${bookId}-list.txt`);
|
|
||||||
|
|
||||||
if (!existsSync(intermediateDir)) {
|
if (!existsSync(intermediateDir)) {
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||||
|
|
@ -151,21 +244,39 @@ export async function GET(request: NextRequest) {
|
||||||
// Read all chapter metadata
|
// Read all chapter metadata
|
||||||
const files = await readdir(intermediateDir);
|
const files = await readdir(intermediateDir);
|
||||||
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
|
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
|
||||||
const chapters: { title: string; duration: number; index: number }[] = [];
|
const chapters: { title: string; duration: number; index: number; format: string }[] = [];
|
||||||
|
|
||||||
for (const metaFile of metaFiles) {
|
for (const metaFile of metaFiles) {
|
||||||
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
|
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
|
||||||
chapters.push(meta);
|
chapters.push(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chapters.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No chapters found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
// Sort chapters by index
|
// Sort chapters by index
|
||||||
chapters.sort((a, b) => a.index - b.index);
|
chapters.sort((a, b) => a.index - b.index);
|
||||||
|
// Determine output format from existing chapter metadata to avoid mismatches
|
||||||
|
const chapterFormat = (chapters[0]?.format === 'mp3' || chapters[0]?.format === 'm4b') ? chapters[0].format : 'm4b';
|
||||||
|
if (requestedFormat && requestedFormat !== chapterFormat) {
|
||||||
|
console.warn(`Requested format ${requestedFormat} differs from chapter format ${chapterFormat}. Using ${chapterFormat}.`);
|
||||||
|
}
|
||||||
|
const format = chapterFormat;
|
||||||
|
const outputPath = join(intermediateDir, `complete.${format}`);
|
||||||
|
const metadataPath = join(intermediateDir, 'metadata.txt');
|
||||||
|
const listPath = join(intermediateDir, 'list.txt');
|
||||||
|
|
||||||
// Create chapter metadata file
|
// Check if combined file already exists
|
||||||
|
if (existsSync(outputPath)) {
|
||||||
|
// Stream the existing file
|
||||||
|
return streamFile(outputPath, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create chapter metadata file for M4B
|
||||||
const metadata: string[] = [];
|
const metadata: string[] = [];
|
||||||
let currentTime = 0;
|
let currentTime = 0;
|
||||||
|
|
||||||
// Calculate chapter timings based on actual durations
|
|
||||||
chapters.forEach((chapter) => {
|
chapters.forEach((chapter) => {
|
||||||
const startMs = Math.floor(currentTime * 1000);
|
const startMs = Math.floor(currentTime * 1000);
|
||||||
currentTime += chapter.duration;
|
currentTime += chapter.duration;
|
||||||
|
|
@ -185,72 +296,86 @@ export async function GET(request: NextRequest) {
|
||||||
// Create list file for concat
|
// Create list file for concat
|
||||||
await writeFile(
|
await writeFile(
|
||||||
listPath,
|
listPath,
|
||||||
chapters.map(c => `file '${join(intermediateDir, `${c.index}.wav`)}'`).join('\n')
|
chapters.map(c => `file '${join(intermediateDir, `${c.index}-chapter.${format}`)}'`).join('\n')
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine all files into a single M4B
|
if (format === 'mp3') {
|
||||||
await runFFmpeg([
|
// For MP3, re-encode to properly rebuild headers and duration metadata
|
||||||
'-f', 'concat',
|
// Using libmp3lame to ensure proper MP3 structure
|
||||||
'-safe', '0',
|
await runFFmpeg([
|
||||||
'-i', listPath,
|
'-f', 'concat',
|
||||||
'-i', metadataPath,
|
'-safe', '0',
|
||||||
'-map_metadata', '1',
|
'-i', listPath,
|
||||||
'-c:a', 'copy', // c:a is codec for audio and :a is stream specifier
|
'-c:a', 'libmp3lame',
|
||||||
//'-codec', 'wav',
|
'-b:a', '64k',
|
||||||
//'-b:a', '192k',
|
outputPath
|
||||||
//'-threads', '0', // Use maximum available threads
|
], request.signal);
|
||||||
//'-movflags', '+faststart',
|
} else {
|
||||||
//'-preset', 'ultrafast', // Use fastest encoding preset
|
// Combine all files into a single M4B with chapter metadata
|
||||||
outputPath
|
await runFFmpeg([
|
||||||
|
'-f', 'concat',
|
||||||
|
'-safe', '0',
|
||||||
|
'-i', listPath,
|
||||||
|
'-i', metadataPath,
|
||||||
|
'-map_metadata', '1',
|
||||||
|
'-c:a', 'copy',
|
||||||
|
'-f', 'mp4',
|
||||||
|
outputPath
|
||||||
|
], request.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temporary files (but keep the chapters and complete file)
|
||||||
|
await Promise.all([
|
||||||
|
unlink(metadataPath).catch(console.error),
|
||||||
|
unlink(listPath).catch(console.error)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Stream the file back to the client
|
// Stream the file back to the client
|
||||||
const stream = createReadStream(outputPath);
|
return streamFile(outputPath, format);
|
||||||
|
|
||||||
// Clean up function
|
|
||||||
const cleanup = async () => {
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
...chapters.map(c => unlink(join(intermediateDir, `${c.index}.wav`))),
|
|
||||||
...chapters.map(c => unlink(join(intermediateDir, `${c.index}.meta.json`))),
|
|
||||||
unlink(metadataPath),
|
|
||||||
unlink(listPath),
|
|
||||||
unlink(outputPath),
|
|
||||||
rmdir(intermediateDir)
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Cleanup error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean up after streaming is complete
|
|
||||||
stream.on('end', cleanup);
|
|
||||||
|
|
||||||
const readableWebStream = new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
stream.on('data', (chunk) => {
|
|
||||||
controller.enqueue(chunk);
|
|
||||||
});
|
|
||||||
stream.on('end', () => {
|
|
||||||
controller.close();
|
|
||||||
});
|
|
||||||
stream.on('error', (err) => {
|
|
||||||
controller.error(err);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new NextResponse(readableWebStream, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'audio/mp4',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'cancelled' },
|
||||||
|
{ status: 499 }
|
||||||
|
);
|
||||||
|
}
|
||||||
console.error('Error creating M4B:', error);
|
console.error('Error creating M4B:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to create M4B file' },
|
{ error: 'Failed to create M4B file' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to stream file
|
||||||
|
function streamFile(filePath: string, format: string) {
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
|
||||||
|
const readableWebStream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||||
|
|
||||||
|
return new NextResponse(readableWebStream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Content-Disposition': `attachment; filename="audiobook.${format}"`,
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,26 +1,40 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { writeFile, mkdir, unlink, readFile } from 'fs/promises';
|
import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
const TEMP_DIR = path.join(process.cwd(), 'temp');
|
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
|
||||||
|
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
|
||||||
|
|
||||||
async function ensureTempDir() {
|
async function ensureTempDir() {
|
||||||
|
if (!existsSync(DOCSTORE_DIR)) {
|
||||||
|
await mkdir(DOCSTORE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
if (!existsSync(TEMP_DIR)) {
|
if (!existsSync(TEMP_DIR)) {
|
||||||
await mkdir(TEMP_DIR, { recursive: true });
|
await mkdir(TEMP_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertDocxToPdf(inputPath: string, outputDir: string): Promise<void> {
|
async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const process = spawn('soffice', [
|
const args: string[] = [];
|
||||||
|
if (profileDir) {
|
||||||
|
// Ensure a per-job profile to isolate concurrent soffice instances
|
||||||
|
// Note: mkdir is async; we prepare the directory before calling this in POST, but safe to include here too
|
||||||
|
// (we avoid awaiting here; POST ensures creation)
|
||||||
|
args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`);
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
'--headless',
|
'--headless',
|
||||||
|
'--nologo',
|
||||||
'--convert-to', 'pdf',
|
'--convert-to', 'pdf',
|
||||||
'--outdir', outputDir,
|
'--outdir', outputDir,
|
||||||
inputPath
|
inputPath
|
||||||
]);
|
);
|
||||||
|
const process = spawn('soffice', args);
|
||||||
|
|
||||||
process.on('error', (error) => {
|
process.on('error', (error) => {
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|
@ -36,6 +50,29 @@ async function convertDocxToPdf(inputPath: string, outputDir: string): Promise<v
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100): Promise<string> {
|
||||||
|
const end = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
const files = await readdir(dir);
|
||||||
|
const pdf = files.find(f => f.toLowerCase().endsWith('.pdf'));
|
||||||
|
if (pdf) {
|
||||||
|
const pdfPath = path.join(dir, pdf);
|
||||||
|
try {
|
||||||
|
const first = await stat(pdfPath);
|
||||||
|
await new Promise((res) => setTimeout(res, intervalMs));
|
||||||
|
const second = await stat(pdfPath);
|
||||||
|
if (second.size > 0 && second.size === first.size) {
|
||||||
|
return pdfPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If stat fails (transient), continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((res) => setTimeout(res, intervalMs));
|
||||||
|
}
|
||||||
|
throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
await ensureTempDir();
|
await ensureTempDir();
|
||||||
|
|
@ -59,24 +96,25 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const tempId = randomUUID();
|
const tempId = randomUUID();
|
||||||
const inputPath = path.join(TEMP_DIR, `${tempId}.docx`);
|
const jobDir = path.join(TEMP_DIR, tempId);
|
||||||
const outputPath = path.join(TEMP_DIR, `${tempId}.pdf`);
|
await mkdir(jobDir, { recursive: true });
|
||||||
|
const profileDir = path.join(jobDir, 'lo-profile');
|
||||||
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
const inputPath = path.join(jobDir, 'input.docx');
|
||||||
|
|
||||||
// Write the uploaded file
|
// Write the uploaded file
|
||||||
await writeFile(inputPath, buffer);
|
await writeFile(inputPath, buffer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert the file
|
// Convert the file
|
||||||
await convertDocxToPdf(inputPath, TEMP_DIR);
|
await convertDocxToPdf(inputPath, jobDir, profileDir);
|
||||||
|
|
||||||
// Return the PDF file
|
// Return the PDF file
|
||||||
const pdfContent = await readFile(outputPath);
|
const pdfPath = await waitForPdfReady(jobDir);
|
||||||
|
const pdfContent = await readFile(pdfPath);
|
||||||
|
|
||||||
// Clean up temp files
|
// Clean up temp files
|
||||||
await Promise.all([
|
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
|
||||||
unlink(inputPath),
|
|
||||||
unlink(outputPath)
|
|
||||||
]).catch(console.error);
|
|
||||||
|
|
||||||
return new NextResponse(pdfContent, {
|
return new NextResponse(pdfContent, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -86,11 +124,7 @@ export async function POST(req: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up temp files on error
|
// Clean up temp files on error
|
||||||
await Promise.all([
|
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
|
||||||
unlink(inputPath),
|
|
||||||
unlink(outputPath)
|
|
||||||
]).catch(console.error);
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { writeFile, readFile, readdir, mkdir, unlink } from 'fs/promises';
|
import { writeFile, readFile, readdir, mkdir, unlink } from 'fs/promises';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import type { BaseDocument, SyncedDocument } from '@/types/documents';
|
||||||
|
|
||||||
const DOCS_DIR = path.join(process.cwd(), 'docstore');
|
const DOCS_DIR = path.join(process.cwd(), 'docstore');
|
||||||
|
|
||||||
|
|
@ -17,9 +18,10 @@ export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
await ensureDocsDir();
|
await ensureDocsDir();
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
|
const documents = data.documents as SyncedDocument[];
|
||||||
|
|
||||||
// Save document metadata and content
|
// Save document metadata and content
|
||||||
for (const doc of data.documents) {
|
for (const doc of documents) {
|
||||||
const docPath = path.join(DOCS_DIR, `${doc.id}.json`);
|
const docPath = path.join(DOCS_DIR, `${doc.id}.json`);
|
||||||
const contentPath = path.join(DOCS_DIR, `${doc.id}.${doc.type}`);
|
const contentPath = path.join(DOCS_DIR, `${doc.id}.${doc.type}`);
|
||||||
|
|
||||||
|
|
@ -49,7 +51,7 @@ export async function POST(req: NextRequest) {
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await ensureDocsDir();
|
await ensureDocsDir();
|
||||||
const documents = [];
|
const documents: SyncedDocument[] = [];
|
||||||
|
|
||||||
const files = await readdir(DOCS_DIR);
|
const files = await readdir(DOCS_DIR);
|
||||||
const jsonFiles = files.filter(file => file.endsWith('.json'));
|
const jsonFiles = files.filter(file => file.endsWith('.json'));
|
||||||
|
|
@ -58,7 +60,7 @@ export async function GET() {
|
||||||
const docPath = path.join(DOCS_DIR, file);
|
const docPath = path.join(DOCS_DIR, file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = JSON.parse(await readFile(docPath, 'utf8'));
|
const metadata = JSON.parse(await readFile(docPath, 'utf8')) as BaseDocument;
|
||||||
const contentPath = path.join(DOCS_DIR, `${metadata.id}.${metadata.type}`);
|
const contentPath = path.join(DOCS_DIR, `${metadata.id}.${metadata.type}`);
|
||||||
const content = await readFile(contentPath);
|
const content = await readFile(contentPath);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,101 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
|
||||||
|
import { isKokoroModel } from '@/utils/voice';
|
||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import type { TTSRequestPayload, TTSError } from '@/types/tts';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
type CustomVoice = string;
|
type CustomVoice = string;
|
||||||
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
||||||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
};
|
};
|
||||||
|
type AudioBufferValue = ArrayBuffer;
|
||||||
|
|
||||||
|
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
|
||||||
|
const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes
|
||||||
|
|
||||||
|
const ttsAudioCache = new LRUCache<string, AudioBufferValue>({
|
||||||
|
maxSize: TTS_CACHE_MAX_SIZE_BYTES,
|
||||||
|
sizeCalculation: (value) => value.byteLength,
|
||||||
|
ttl: TTS_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
type InflightEntry = {
|
||||||
|
promise: Promise<ArrayBuffer>;
|
||||||
|
controller: AbortController;
|
||||||
|
consumers: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inflightRequests = new Map<string, InflightEntry>();
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((res) => setTimeout(res, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTTSBufferWithRetry(
|
||||||
|
openai: OpenAI,
|
||||||
|
createParams: ExtendedSpeechParams,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
let attempt = 0;
|
||||||
|
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
||||||
|
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
||||||
|
const maxDelay = Number(process.env.TTS_RETRY_MAX_MS ?? 2000);
|
||||||
|
const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2);
|
||||||
|
|
||||||
|
// Retry on 429 and 5xx only; never retry aborts
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal });
|
||||||
|
return await response.arrayBuffer();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (signal?.aborted || (err instanceof Error && err.name === 'AbortError')) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const status = (() => {
|
||||||
|
if (typeof err === 'object' && err !== null) {
|
||||||
|
const rec = err as Record<string, unknown>;
|
||||||
|
if (typeof rec.status === 'number') return rec.status as number;
|
||||||
|
if (typeof rec.statusCode === 'number') return rec.statusCode as number;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
})();
|
||||||
|
const retryable = status === 429 || status >= 500;
|
||||||
|
if (!retryable || attempt >= maxRetries) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await sleep(Math.min(delay, maxDelay));
|
||||||
|
delay = Math.min(maxDelay, delay * backoff);
|
||||||
|
attempt += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCacheKey(input: {
|
||||||
|
provider: string;
|
||||||
|
model: string | null | undefined;
|
||||||
|
voice: string | undefined;
|
||||||
|
speed: number;
|
||||||
|
format: string;
|
||||||
|
text: string;
|
||||||
|
instructions?: string;
|
||||||
|
}) {
|
||||||
|
const canonical = {
|
||||||
|
provider: input.provider,
|
||||||
|
model: input.model || '',
|
||||||
|
voice: input.voice || '',
|
||||||
|
speed: input.speed,
|
||||||
|
format: input.format,
|
||||||
|
text: input.text,
|
||||||
|
// Only include instructions when present (for models like gpt-4o-mini-tts)
|
||||||
|
instructions: input.instructions || undefined,
|
||||||
|
};
|
||||||
|
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -14,52 +103,163 @@ export async function POST(req: NextRequest) {
|
||||||
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
|
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
|
||||||
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||||
const { text, voice, speed, format, model, instructions } = await req.json();
|
const body = (await req.json()) as TTSRequestPayload;
|
||||||
console.log('Received TTS request:', text, voice, speed, format, model);
|
const { text, voice, speed, format, model: req_model, instructions } = body;
|
||||||
|
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
|
||||||
if (!openApiKey) {
|
|
||||||
return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text || !voice || !speed) {
|
if (!text || !voice || !speed) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
const errorBody: TTSError = {
|
||||||
|
code: 'MISSING_PARAMETERS',
|
||||||
|
message: 'Missing required parameters',
|
||||||
|
};
|
||||||
|
return NextResponse.json(errorBody, { status: 400 });
|
||||||
}
|
}
|
||||||
|
// Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default
|
||||||
|
const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model;
|
||||||
|
const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
|
||||||
|
|
||||||
// Apply Deepinfra defaults if provider is deepinfra
|
// Initialize OpenAI client with abort signal (OpenAI/deepinfra)
|
||||||
const finalModel = provider === 'deepinfra' && !model ? 'hexgrad/Kokoro-82M' : model;
|
|
||||||
const finalVoice = provider === 'deepinfra' && !voice ? 'af_bella' : voice;
|
|
||||||
|
|
||||||
// Initialize OpenAI client with abort signal
|
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: openApiKey,
|
apiKey: openApiKey,
|
||||||
baseURL: openApiBaseUrl,
|
baseURL: openApiBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request audio from OpenAI and pass along the abort signal
|
const normalizedVoice = (
|
||||||
|
!isKokoroModel(model as string) && voice.includes('+')
|
||||||
|
? (voice.split('+')[0].trim())
|
||||||
|
: voice
|
||||||
|
) as SpeechCreateParams['voice'];
|
||||||
|
|
||||||
const createParams: ExtendedSpeechParams = {
|
const createParams: ExtendedSpeechParams = {
|
||||||
model: finalModel || 'tts-1',
|
model: model,
|
||||||
voice: finalVoice as "alloy",
|
voice: normalizedVoice,
|
||||||
input: text,
|
input: text,
|
||||||
speed: speed,
|
speed: speed,
|
||||||
response_format: format === 'aac' ? 'aac' : 'mp3',
|
response_format: format === 'aac' ? 'aac' : 'mp3',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
||||||
if (finalModel === 'gpt-4o-mini-tts' && instructions) {
|
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
|
||||||
createParams.instructions = instructions;
|
createParams.instructions = instructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal: req.signal });
|
// Compute cache key and check LRU before making provider call
|
||||||
|
|
||||||
// Get the audio data as array buffer
|
|
||||||
// This will also be aborted if the client cancels
|
|
||||||
const stream = response.body;
|
|
||||||
|
|
||||||
// Return audio data with appropriate headers
|
|
||||||
const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg';
|
const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg';
|
||||||
return new NextResponse(stream, {
|
|
||||||
|
// Preserve voice string as-is for cache key (no weight stripping)
|
||||||
|
const voiceForKey = typeof createParams.voice === 'string'
|
||||||
|
? createParams.voice
|
||||||
|
: String(createParams.voice);
|
||||||
|
|
||||||
|
const cacheKey = makeCacheKey({
|
||||||
|
provider,
|
||||||
|
model: createParams.model,
|
||||||
|
voice: voiceForKey,
|
||||||
|
speed: Number(createParams.speed),
|
||||||
|
format: String(createParams.response_format),
|
||||||
|
text,
|
||||||
|
instructions: createParams.instructions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const etag = `W/"${cacheKey}"`;
|
||||||
|
const ifNoneMatch = req.headers.get('if-none-match');
|
||||||
|
|
||||||
|
const cachedBuffer = ttsAudioCache.get(cacheKey);
|
||||||
|
if (cachedBuffer) {
|
||||||
|
if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
'ETag': etag,
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('TTS cache HIT for key:', cacheKey.slice(0, 8));
|
||||||
|
return new NextResponse(cachedBuffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'X-Cache': 'HIT',
|
||||||
|
'ETag': etag,
|
||||||
|
'Content-Length': String(cachedBuffer.byteLength),
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate identical in-flight requests
|
||||||
|
const existing = inflightRequests.get(cacheKey);
|
||||||
|
if (existing) {
|
||||||
|
console.log('TTS in-flight JOIN for key:', cacheKey.slice(0, 8));
|
||||||
|
existing.consumers += 1;
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
existing.consumers = Math.max(0, existing.consumers - 1);
|
||||||
|
if (existing.consumers === 0) {
|
||||||
|
existing.controller.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await existing.promise;
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'X-Cache': 'INFLIGHT',
|
||||||
|
'ETag': etag,
|
||||||
|
'Content-Length': String(buffer.byteLength),
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
try { req.signal.removeEventListener('abort', onAbort); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const entry: InflightEntry = {
|
||||||
|
controller,
|
||||||
|
consumers: 1,
|
||||||
|
promise: (async () => {
|
||||||
|
try {
|
||||||
|
const buffer = await fetchTTSBufferWithRetry(openai, createParams, controller.signal);
|
||||||
|
// Save to cache
|
||||||
|
ttsAudioCache.set(cacheKey, buffer);
|
||||||
|
return buffer;
|
||||||
|
} finally {
|
||||||
|
inflightRequests.delete(cacheKey);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
};
|
||||||
|
|
||||||
|
inflightRequests.set(cacheKey, entry);
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
entry.consumers = Math.max(0, entry.consumers - 1);
|
||||||
|
if (entry.consumers === 0) {
|
||||||
|
entry.controller.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
|
let buffer: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
buffer = await entry.promise;
|
||||||
|
} finally {
|
||||||
|
try { req.signal.removeEventListener('abort', onAbort); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType
|
'Content-Type': contentType,
|
||||||
|
'X-Cache': 'MISS',
|
||||||
|
'ETag': etag,
|
||||||
|
'Content-Length': String(buffer.byteLength),
|
||||||
|
'Cache-Control': 'private, max-age=1800',
|
||||||
|
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -70,8 +270,13 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('Error generating TTS:', error);
|
console.warn('Error generating TTS:', error);
|
||||||
|
const errorBody: TTSError = {
|
||||||
|
code: 'TTS_GENERATION_FAILED',
|
||||||
|
message: 'Failed to generate audio',
|
||||||
|
details: process.env.NODE_ENV !== 'production' ? String(error) : undefined,
|
||||||
|
};
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to generate audio' },
|
errorBody,
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isKokoroModel } from '@/utils/voice';
|
||||||
|
|
||||||
const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
|
const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
|
||||||
const GPT4O_MINI_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
const GPT4O_MINI_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
||||||
|
|
@ -29,6 +30,10 @@ function getDefaultVoices(provider: string, model: string): string[] {
|
||||||
|
|
||||||
// For Custom OpenAI-Like provider
|
// For Custom OpenAI-Like provider
|
||||||
if (provider === 'custom-openai') {
|
if (provider === 'custom-openai') {
|
||||||
|
// If using Kokoro-FastAPI (model string contains 'kokoro'), expose full Kokoro voices
|
||||||
|
if (isKokoroModel(model)) {
|
||||||
|
return KOKORO_VOICES;
|
||||||
|
}
|
||||||
return CUSTOM_OPENAI_VOICES;
|
return CUSTOM_OPENAI_VOICES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +77,7 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
//console.log('Deepinfra voices response:', data);
|
|
||||||
// Extract voice names from the response, excluding preset voices
|
// Extract voice names from the response, excluding preset voices
|
||||||
if (data.voices && Array.isArray(data.voices)) {
|
if (data.voices && Array.isArray(data.voices)) {
|
||||||
return data.voices
|
return data.voices
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,26 @@ import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useEPUB } from '@/contexts/EPUBContext';
|
import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { EPUBViewer } from '@/components/EPUBViewer';
|
import { EPUBViewer } from '@/components/EPUBViewer';
|
||||||
import { Button } from '@headlessui/react';
|
|
||||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||||
import { SettingsIcon } from '@/components/icons/Icons';
|
import { SettingsIcon } from '@/components/icons/Icons';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
import { useTTS } from "@/contexts/TTSContext";
|
import { useTTS } from "@/contexts/TTSContext";
|
||||||
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
import { ZoomControl } from '@/components/ZoomControl';
|
||||||
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
|
import { DownloadIcon } from '@/components/icons/Icons';
|
||||||
|
|
||||||
export default function EPUBPage() {
|
export default function EPUBPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB();
|
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
||||||
const { stop } = useTTS();
|
const { stop } = useTTS();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||||
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
|
||||||
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
console.log('Loading new epub (from page.tsx)');
|
console.log('Loading new epub (from page.tsx)');
|
||||||
|
|
@ -43,6 +51,54 @@ export default function EPUBPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [loadDocument, isLoading]);
|
}, [loadDocument, isLoading]);
|
||||||
|
|
||||||
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
|
useEffect(() => {
|
||||||
|
const compute = () => {
|
||||||
|
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
|
||||||
|
const ttsbar = document.querySelector('[data-app-ttsbar]') as HTMLElement | null;
|
||||||
|
const headerH = header ? header.getBoundingClientRect().height : 0;
|
||||||
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const h = Math.max(0, vh - headerH - ttsH);
|
||||||
|
setContainerHeight(`${h}px`);
|
||||||
|
|
||||||
|
// compute max horizontal padding while preserving a minimum readable width,
|
||||||
|
// but still allow some padding on small screens
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const desiredMin = 640; // target readable min width
|
||||||
|
const minContent = Math.min(desiredMin, Math.max(320, vw - 32));
|
||||||
|
const maxPad = Math.max(0, Math.floor((vw - minContent) / 2));
|
||||||
|
setMaxPadPx(maxPad);
|
||||||
|
};
|
||||||
|
compute();
|
||||||
|
window.addEventListener('resize', compute);
|
||||||
|
return () => window.removeEventListener('resize', compute);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Nudge EPUB renderer to reflow on horizontal padding changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Some EPUB renderers listen to window resize; emit a synthetic event
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
}, [padPct]);
|
||||||
|
|
||||||
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||||
|
format: 'mp3' | 'm4b'
|
||||||
|
) => {
|
||||||
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
|
}, [createEPUBAudioBook, id]);
|
||||||
|
|
||||||
|
const handleRegenerateChapter = useCallback(async (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
signal: AbortSignal
|
||||||
|
) => {
|
||||||
|
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
||||||
|
}, [regenerateEPUBChapter]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -50,7 +106,7 @@ export default function EPUBPage() {
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => clearCurrDoc()}
|
onClick={() => clearCurrDoc()}
|
||||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
|
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
|
@ -63,39 +119,68 @@ export default function EPUBPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-2 pb-2 border-b border-offbase">
|
<Header
|
||||||
<div className="flex flex-wrap items-center justify-between">
|
left={
|
||||||
<div className="flex items-center gap-1">
|
<Link
|
||||||
<Link
|
href="/"
|
||||||
href="/"
|
onClick={() => clearCurrDoc()}
|
||||||
onClick={() => clearCurrDoc()}
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
aria-label="Back to documents"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Documents
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||||
|
right={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ZoomControl
|
||||||
|
value={padPct}
|
||||||
|
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
|
||||||
|
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
|
aria-label="Open audiobook export"
|
||||||
|
title="Export Audiobook"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
</button>
|
||||||
</svg>
|
<button
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="View Settings"
|
aria-label="Open settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
}
|
||||||
{isLoading ? 'Loading...' : currDocName}
|
/>
|
||||||
</h1>
|
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||||
</div>
|
{isLoading ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<DocumentSkeleton />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
|
||||||
|
<EPUBViewer className="h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
<AudiobookExportModal
|
||||||
<div className="p-4">
|
isOpen={isAudiobookModalOpen}
|
||||||
<DocumentSkeleton />
|
setIsOpen={setIsAudiobookModalOpen}
|
||||||
</div>
|
documentType="epub"
|
||||||
) : (
|
documentId={id as string}
|
||||||
<EPUBViewer className="p-4" />
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
)}
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
|
/>
|
||||||
|
<TTSPlayer />
|
||||||
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ html.light {
|
||||||
--base: #f7fafc;
|
--base: #f7fafc;
|
||||||
--offbase: #e2e8f0;
|
--offbase: #e2e8f0;
|
||||||
--accent: #ef4444;
|
--accent: #ef4444;
|
||||||
|
--secondary-accent: #ed6868;
|
||||||
--muted: #718096;
|
--muted: #718096;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#fecaca,
|
||||||
|
#f87171,
|
||||||
|
#ef4444
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
|
|
@ -19,7 +25,13 @@ html.dark {
|
||||||
--base: #171717;
|
--base: #171717;
|
||||||
--offbase: #343434;
|
--offbase: #343434;
|
||||||
--accent: #f87171;
|
--accent: #f87171;
|
||||||
|
--secondary-accent: #eb6262;
|
||||||
--muted: #a3a3a3;
|
--muted: #a3a3a3;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#fca5a5,
|
||||||
|
#fb7185,
|
||||||
|
#f87171
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.ocean {
|
html.ocean {
|
||||||
|
|
@ -28,7 +40,13 @@ html.ocean {
|
||||||
--base: #0f172a;
|
--base: #0f172a;
|
||||||
--offbase: #1e293b;
|
--offbase: #1e293b;
|
||||||
--accent: #38bdf8;
|
--accent: #38bdf8;
|
||||||
|
--secondary-accent: #22d3ee;
|
||||||
--muted: #94a3b8;
|
--muted: #94a3b8;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#7dd3fc,
|
||||||
|
#38bdf8,
|
||||||
|
#0ea5e9
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.forest {
|
html.forest {
|
||||||
|
|
@ -37,7 +55,13 @@ html.forest {
|
||||||
--base: #111a15;
|
--base: #111a15;
|
||||||
--offbase: #1a2820;
|
--offbase: #1a2820;
|
||||||
--accent: #4ade80;
|
--accent: #4ade80;
|
||||||
|
--secondary-accent: #22c55e;
|
||||||
--muted: #7c8f85;
|
--muted: #7c8f85;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#86efac,
|
||||||
|
#4ade80,
|
||||||
|
#22c55e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.sunset {
|
html.sunset {
|
||||||
|
|
@ -46,7 +70,13 @@ html.sunset {
|
||||||
--base: #2c1810;
|
--base: #2c1810;
|
||||||
--offbase: #3d1f14;
|
--offbase: #3d1f14;
|
||||||
--accent: #ff6b6b;
|
--accent: #ff6b6b;
|
||||||
|
--secondary-accent: #f59e0b;
|
||||||
--muted: #bc8f8f;
|
--muted: #bc8f8f;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#fca5a5,
|
||||||
|
#fb7185,
|
||||||
|
#ff6b6b
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.sea {
|
html.sea {
|
||||||
|
|
@ -55,7 +85,13 @@ html.sea {
|
||||||
--base: #102c3d;
|
--base: #102c3d;
|
||||||
--offbase: #1a3c52;
|
--offbase: #1a3c52;
|
||||||
--accent: #06b6d4;
|
--accent: #06b6d4;
|
||||||
|
--secondary-accent: #0ea5e9;
|
||||||
--muted: #7ca7c4;
|
--muted: #7ca7c4;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#67e8f9,
|
||||||
|
#22d3ee,
|
||||||
|
#06b6d4
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.mint {
|
html.mint {
|
||||||
|
|
@ -64,7 +100,13 @@ html.mint {
|
||||||
--base: #132d27;
|
--base: #132d27;
|
||||||
--offbase: #1c3d35;
|
--offbase: #1c3d35;
|
||||||
--accent: #2dd4bf;
|
--accent: #2dd4bf;
|
||||||
|
--secondary-accent: #10b981;
|
||||||
--muted: #75a99c;
|
--muted: #75a99c;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#99f6e4,
|
||||||
|
#5eead4,
|
||||||
|
#2dd4bf
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -76,3 +118,75 @@ body {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* App shell utility (fullscreen, vertical layout) */
|
||||||
|
.app-shell {
|
||||||
|
--header-height: 3.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Themed overlay using foreground color without relying on /opacity classes */
|
||||||
|
.overlay-dim {
|
||||||
|
background-color: color-mix(in srgb, var(--foreground), transparent 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for better fullscreen experience */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: var(--base);
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--offbase);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--base);
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle prism animated outline around list items (very light), matches the row's rounded-lg curve exactly */
|
||||||
|
.prism-outline {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
/* do not set border-radius here; let the element's rounded-lg from Tailwind define the curve */
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--card-fill, var(--offbase)), var(--card-fill, var(--offbase))) padding-box,
|
||||||
|
var(--prism-gradient) border-box;
|
||||||
|
background-clip: padding-box, border-box;
|
||||||
|
background-size: 300% 300%;
|
||||||
|
animation: prism-shift 8s linear infinite, prism-fade 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prism-shift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
100% { background-position: 300% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gentle opacity pulse to simulate a "breath" without shadows spilling outside */
|
||||||
|
@keyframes prism-fade {
|
||||||
|
0% { opacity: 0.8; }
|
||||||
|
50% { opacity: 1.0; }
|
||||||
|
100% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Static prism gradient divider (no animation) */
|
||||||
|
.prism-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.7px; /* use 2px then feather for a crisper center */
|
||||||
|
position: relative;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.prism-divider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--prism-gradient);
|
||||||
|
/* Feather edges so they appear thinner */
|
||||||
|
mask: linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%);
|
||||||
|
-webkit-mask: linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%);
|
||||||
|
border-radius: 999px; /* subtle rounding to reinforce taper */
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useHTML } from '@/contexts/HTMLContext';
|
import { useHTML } from '@/contexts/HTMLContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { HTMLViewer } from '@/components/HTMLViewer';
|
import { HTMLViewer } from '@/components/HTMLViewer';
|
||||||
import { Button } from '@headlessui/react';
|
|
||||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||||
import { SettingsIcon } from '@/components/icons/Icons';
|
import { SettingsIcon } from '@/components/icons/Icons';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
import { useTTS } from "@/contexts/TTSContext";
|
import { useTTS } from "@/contexts/TTSContext";
|
||||||
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
import { ZoomControl } from '@/components/ZoomControl';
|
||||||
|
|
||||||
export default function HTMLPage() {
|
export default function HTMLPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
@ -18,6 +20,9 @@ export default function HTMLPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
||||||
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
|
|
@ -41,6 +46,29 @@ export default function HTMLPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [loadDocument]);
|
}, [loadDocument]);
|
||||||
|
|
||||||
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
|
useEffect(() => {
|
||||||
|
const compute = () => {
|
||||||
|
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
|
||||||
|
const ttsbar = document.querySelector('[data-app-ttsbar]') as HTMLElement | null;
|
||||||
|
const headerH = header ? header.getBoundingClientRect().height : 0;
|
||||||
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const h = Math.max(0, vh - headerH - ttsH);
|
||||||
|
setContainerHeight(`${h}px`);
|
||||||
|
|
||||||
|
// Adaptive minimum content width: allow some padding on narrow screens
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const desiredMin = 640;
|
||||||
|
const minContent = Math.min(desiredMin, Math.max(320, vw - 32));
|
||||||
|
const maxPad = Math.max(0, Math.floor((vw - minContent) / 2));
|
||||||
|
setMaxPadPx(maxPad);
|
||||||
|
};
|
||||||
|
compute();
|
||||||
|
window.addEventListener('resize', compute);
|
||||||
|
return () => window.removeEventListener('resize', compute);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -48,7 +76,7 @@ export default function HTMLPage() {
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => {clearCurrDoc();}}
|
onClick={() => {clearCurrDoc();}}
|
||||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
|
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
|
@ -61,39 +89,52 @@ export default function HTMLPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-2 pb-2 border-b border-offbase">
|
<Header
|
||||||
<div className="flex flex-wrap items-center justify-between">
|
left={
|
||||||
<div className="flex items-center gap-1">
|
<Link
|
||||||
<Link
|
href="/"
|
||||||
href="/"
|
onClick={() => clearCurrDoc()}
|
||||||
onClick={() => {clearCurrDoc();}}
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
aria-label="Back to documents"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
</svg>
|
</svg>
|
||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
}
|
||||||
|
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||||
|
right={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ZoomControl
|
||||||
|
value={padPct}
|
||||||
|
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
|
||||||
|
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
|
className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="View Settings"
|
aria-label="Open settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
}
|
||||||
{isLoading ? 'Loading...' : currDocName}
|
/>
|
||||||
</h1>
|
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||||
</div>
|
{isLoading ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<DocumentSkeleton />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
|
||||||
|
<HTMLViewer className="h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
<TTSPlayer />
|
||||||
<div className="p-4">
|
|
||||||
<DocumentSkeleton />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<HTMLViewer className="p-4" />
|
|
||||||
)}
|
|
||||||
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Providers } from "@/app/providers";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { Analytics } from "@vercel/analytics/next"
|
import { Analytics } from "@vercel/analytics/next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "OpenReader WebUI",
|
title: "OpenReader WebUI",
|
||||||
|
|
@ -56,14 +56,16 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<Providers>
|
<Providers>
|
||||||
<div className="min-h-screen bg-background p-4">
|
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||||
<div className="relative max-w-6xl mx-auto align-center">
|
<main className="flex-1 flex flex-col">
|
||||||
<div className="bg-base rounded-lg shadow-lg">
|
{children}
|
||||||
{children}
|
<Analytics />
|
||||||
<Analytics />
|
</main>
|
||||||
|
{!isDev && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
{!isDev && <Footer />}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,27 @@
|
||||||
import { DocumentUploader } from '@/components/DocumentUploader';
|
import { HomeContent } from '@/components/HomeContent';
|
||||||
import { DocumentList } from '@/components/doclist/DocumentList';
|
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
|
|
||||||
|
// Home page redesigned for fullscreen layout: hero + document area.
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className='p-3.5 sm:p-5'>
|
<div className="flex flex-col h-full w-full">
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<h1 className="text-xl font-bold text-center flex-grow">OpenReader WebUI</h1>
|
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
||||||
<p className="text-sm mt-1 text-center text-muted mb-5">A bring your own text-to-speech api web interface for reading documents with high quality voices</p>
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex flex-col items-center gap-5">
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
|
||||||
<DocumentUploader className='max-w-xl' />
|
<p className="text-sm leading-relaxed max-w-prose text-foreground">
|
||||||
<DocumentList />
|
Bring your own text-to-speech API.
|
||||||
</div>
|
<span className="block font-medium">Read & listen to PDF, EPUB & HTML documents with high quality voices.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex-1 px-4 pb-8 overflow-auto">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
|
||||||
|
<HomeContent />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@ import Link from 'next/link';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { Button } from '@headlessui/react';
|
|
||||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||||
import { SettingsIcon } from '@/components/icons/Icons';
|
import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { ZoomControl } from '@/components/ZoomControl';
|
||||||
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
|
||||||
// Dynamic import for client-side rendering only
|
// Dynamic import for client-side rendering only
|
||||||
const PDFViewer = dynamic(
|
const PDFViewer = dynamic(
|
||||||
|
|
@ -22,12 +25,14 @@ const PDFViewer = dynamic(
|
||||||
|
|
||||||
export default function PDFViewerPage() {
|
export default function PDFViewerPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { setCurrentDocument, currDocName, clearCurrDoc } = usePDF();
|
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
||||||
const { stop } = useTTS();
|
const { stop } = useTTS();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||||
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
if (!isLoading) return; // Prevent calls when not loading new doc
|
if (!isLoading) return; // Prevent calls when not loading new doc
|
||||||
|
|
@ -51,9 +56,43 @@ export default function PDFViewerPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [loadDocument]);
|
}, [loadDocument]);
|
||||||
|
|
||||||
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
|
useEffect(() => {
|
||||||
|
const compute = () => {
|
||||||
|
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
|
||||||
|
const ttsbar = document.querySelector('[data-app-ttsbar]') as HTMLElement | null;
|
||||||
|
const headerH = header ? header.getBoundingClientRect().height : 0;
|
||||||
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const h = Math.max(0, vh - headerH - ttsH);
|
||||||
|
setContainerHeight(`${h}px`);
|
||||||
|
};
|
||||||
|
compute();
|
||||||
|
window.addEventListener('resize', compute);
|
||||||
|
return () => window.removeEventListener('resize', compute);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300));
|
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300));
|
||||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
||||||
|
|
||||||
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||||
|
format: 'mp3' | 'm4b'
|
||||||
|
) => {
|
||||||
|
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
|
}, [createPDFAudioBook, id]);
|
||||||
|
|
||||||
|
const handleRegenerateChapter = useCallback(async (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
signal: AbortSignal
|
||||||
|
) => {
|
||||||
|
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
||||||
|
}, [regeneratePDFChapter]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -61,7 +100,7 @@ export default function PDFViewerPage() {
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => {clearCurrDoc();}}
|
onClick={() => {clearCurrDoc();}}
|
||||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
|
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
|
@ -74,56 +113,60 @@ export default function PDFViewerPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-2 pb-2 border-b border-offbase">
|
<Header
|
||||||
<div className="flex flex-wrap items-center justify-between">
|
left={
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
onClick={() => clearCurrDoc()}
|
||||||
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
|
aria-label="Back to documents"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Documents
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||||
|
right={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
|
||||||
href="/"
|
<button
|
||||||
onClick={() => {clearCurrDoc();}}
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
|
aria-label="Open audiobook export"
|
||||||
|
title="Export Audiobook"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
</button>
|
||||||
</svg>
|
<button
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
<div className="bg-offbase px-2 py-0.5 rounded-full flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
className="text-xs transform transition-transform duration-200 ease-in-out hover:scale-[1.45] hover:font-semibold hover:text-accent"
|
|
||||||
aria-label="Zoom out"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs">{zoomLevel}%</span>
|
|
||||||
<Button
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
className="text-xs transform transition-transform duration-200 ease-in-out hover:scale-[1.45] hover:font-semibold hover:text-accent"
|
|
||||||
aria-label="Zoom in"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="View Settings"
|
aria-label="Open settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
}
|
||||||
{isLoading ? 'Loading...' : currDocName}
|
/>
|
||||||
</h1>
|
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||||
</div>
|
{isLoading ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<DocumentSkeleton />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PDFViewer zoomLevel={zoomLevel} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
<AudiobookExportModal
|
||||||
<div className="p-4">
|
isOpen={isAudiobookModalOpen}
|
||||||
<DocumentSkeleton />
|
setIsOpen={setIsAudiobookModalOpen}
|
||||||
</div>
|
documentType="pdf"
|
||||||
) : (
|
documentId={id as string}
|
||||||
<PDFViewer zoomLevel={zoomLevel} />
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
)}
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
|
/>
|
||||||
|
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
||||||
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
754
src/components/AudiobookExportModal.tsx
Normal file
754
src/components/AudiobookExportModal.tsx
Normal file
|
|
@ -0,0 +1,754 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Fragment, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
|
||||||
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
|
import { ProgressCard } from '@/components/ProgressCard';
|
||||||
|
import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons';
|
||||||
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
|
interface AudiobookExportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
documentType: 'epub' | 'pdf';
|
||||||
|
documentId: string;
|
||||||
|
onGenerateAudiobook: (
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
|
format: 'mp3' | 'm4b'
|
||||||
|
) => Promise<string>; // Returns bookId
|
||||||
|
onRegenerateChapter?: (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
signal: AbortSignal
|
||||||
|
) => Promise<TTSAudiobookChapter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudiobookExportModal({
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
documentType,
|
||||||
|
documentId,
|
||||||
|
onGenerateAudiobook,
|
||||||
|
onRegenerateChapter
|
||||||
|
}: AudiobookExportModalProps) {
|
||||||
|
const { isLoading, isDBReady } = useConfig();
|
||||||
|
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]);
|
||||||
|
const [bookId, setBookId] = useState<string | null>(null);
|
||||||
|
const [isCombining, setIsCombining] = useState(false);
|
||||||
|
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
||||||
|
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
|
||||||
|
const [currentChapter, setCurrentChapter] = useState<string>('');
|
||||||
|
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
|
||||||
|
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [showRegenerateHint, setShowRegenerateHint] = useState(false);
|
||||||
|
|
||||||
|
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
||||||
|
if (soft) {
|
||||||
|
setIsRefreshingChapters(true);
|
||||||
|
} else {
|
||||||
|
setIsLoadingExisting(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/audio/convert/chapters?bookId=${documentId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.exists && data.chapters.length > 0) {
|
||||||
|
setChapters(data.chapters);
|
||||||
|
setBookId(data.bookId);
|
||||||
|
// Set format from existing chapters - this ensures the format matches what was actually generated
|
||||||
|
if (data.chapters[0]?.format) {
|
||||||
|
const detectedFormat = data.chapters[0].format as 'mp3' | 'm4b';
|
||||||
|
setFormat(detectedFormat);
|
||||||
|
}
|
||||||
|
// If we have a complete audiobook, we're done
|
||||||
|
if (data.hasComplete) {
|
||||||
|
setProgress(100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If nothing exists, clear chapters/bookId to reflect current state
|
||||||
|
setChapters([]);
|
||||||
|
setBookId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching existing chapters:', error);
|
||||||
|
} finally {
|
||||||
|
if (soft) {
|
||||||
|
setIsRefreshingChapters(false);
|
||||||
|
} else {
|
||||||
|
setIsLoadingExisting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [documentId, setProgress]);
|
||||||
|
|
||||||
|
// Fetch existing chapters when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && documentId && !isGenerating) {
|
||||||
|
fetchExistingChapters();
|
||||||
|
}
|
||||||
|
}, [isOpen, documentId, isGenerating, fetchExistingChapters]);
|
||||||
|
|
||||||
|
const handleChapterComplete = useCallback((chapter: TTSAudiobookChapter) => {
|
||||||
|
setChapters(prev => {
|
||||||
|
const existing = prev.find(c => c.index === chapter.index);
|
||||||
|
if (existing) {
|
||||||
|
return prev.map(c => c.index === chapter.index ? chapter : c);
|
||||||
|
}
|
||||||
|
return [...prev, chapter].sort((a, b) => a.index - b.index);
|
||||||
|
});
|
||||||
|
setCurrentChapter(chapter.title);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartGeneration = useCallback(async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
setProgress(0);
|
||||||
|
setCurrentChapter('');
|
||||||
|
// Don't clear chapters if resuming
|
||||||
|
if (!bookId) {
|
||||||
|
setChapters([]);
|
||||||
|
setBookId(null);
|
||||||
|
}
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const generatedBookId = await onGenerateAudiobook(
|
||||||
|
(progress) => setProgress(progress),
|
||||||
|
abortControllerRef.current.signal,
|
||||||
|
handleChapterComplete,
|
||||||
|
format
|
||||||
|
);
|
||||||
|
setBookId(generatedBookId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating audiobook:', error);
|
||||||
|
if (error instanceof Error && error.message.includes('cancelled')) {
|
||||||
|
// Graceful cancellation - chapters are saved
|
||||||
|
console.log('Audiobook generation cancelled gracefully');
|
||||||
|
} else {
|
||||||
|
// Show error to user for actual errors
|
||||||
|
setErrorMessage('Failed to generate audiobook. Please try again.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setProgress(0);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
// Refresh chapters to show what was completed (soft refresh list only)
|
||||||
|
if (bookId || documentId) {
|
||||||
|
await fetchExistingChapters(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [onGenerateAudiobook, handleChapterComplete, setProgress, bookId, format, documentId, fetchExistingChapters]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cancel in-flight conversion if the page is being hidden or the component unmounts
|
||||||
|
// (e.g., user navigates away from the document to the home screen).
|
||||||
|
useEffect(() => {
|
||||||
|
const onPageHide = () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('pagehide', onPageHide);
|
||||||
|
window.addEventListener('beforeunload', onPageHide);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
|
window.removeEventListener('beforeunload', onPageHide);
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRegenerateChapter = useCallback(async (chapter: TTSAudiobookChapter) => {
|
||||||
|
if (!onRegenerateChapter || !bookId) return;
|
||||||
|
|
||||||
|
if (!showRegenerateHint) {
|
||||||
|
setShowRegenerateHint(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRegeneratingChapter(chapter.index);
|
||||||
|
setCurrentChapter(`Regenerating: ${chapter.title}`);
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update chapter status to generating
|
||||||
|
setChapters(prev => {
|
||||||
|
const exists = prev.some(c => c.index === chapter.index);
|
||||||
|
if (exists) {
|
||||||
|
return prev.map(c =>
|
||||||
|
c.index === chapter.index
|
||||||
|
? { ...c, status: 'generating' as const }
|
||||||
|
: c
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If it's a missing placeholder, add it as generating
|
||||||
|
return [...prev, { ...chapter, status: 'generating' as const }].sort((a, b) => a.index - b.index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const regeneratedChapter = await onRegenerateChapter(
|
||||||
|
chapter.index,
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
abortControllerRef.current.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update chapter with new data
|
||||||
|
setChapters(prev => prev.map(c =>
|
||||||
|
c.index === chapter.index
|
||||||
|
? regeneratedChapter
|
||||||
|
: c
|
||||||
|
));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error regenerating chapter:', error);
|
||||||
|
if (error instanceof Error && error.message.includes('cancelled')) {
|
||||||
|
console.log('Chapter regeneration cancelled');
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Failed to regenerate chapter. Please try again.');
|
||||||
|
// Mark as error
|
||||||
|
setChapters(prev => prev.map(c =>
|
||||||
|
c.index === chapter.index
|
||||||
|
? { ...c, status: 'error' as const }
|
||||||
|
: c
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRegeneratingChapter(null);
|
||||||
|
setCurrentChapter('');
|
||||||
|
setProgress(0);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
// Refresh chapters to get updated data (soft refresh list only)
|
||||||
|
await fetchExistingChapters(true);
|
||||||
|
}
|
||||||
|
}, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters, showRegenerateHint]);
|
||||||
|
|
||||||
|
const performDeleteChapter = useCallback(async () => {
|
||||||
|
if (!bookId || !pendingDeleteChapter) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/audio/convert/chapter?bookId=${bookId}&chapterIndex=${pendingDeleteChapter.index}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Delete failed');
|
||||||
|
}
|
||||||
|
setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index));
|
||||||
|
await fetchExistingChapters(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting chapter:', error);
|
||||||
|
setErrorMessage('Failed to delete chapter. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setPendingDeleteChapter(null);
|
||||||
|
}
|
||||||
|
}, [bookId, pendingDeleteChapter, fetchExistingChapters]);
|
||||||
|
|
||||||
|
const performResetAll = useCallback(async () => {
|
||||||
|
const targetBookId = bookId || documentId;
|
||||||
|
if (!targetBookId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/audio/convert/chapters?bookId=${targetBookId}`, { method: 'DELETE' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error('Reset failed');
|
||||||
|
}
|
||||||
|
setChapters([]);
|
||||||
|
setBookId(null);
|
||||||
|
setProgress(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting audiobook chapters:', error);
|
||||||
|
setErrorMessage('Failed to reset chapters. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setShowResetConfirm(false);
|
||||||
|
await fetchExistingChapters(true);
|
||||||
|
}
|
||||||
|
}, [bookId, documentId, setProgress, fetchExistingChapters]);
|
||||||
|
|
||||||
|
const handleDownloadChapter = useCallback(async (chapter: TTSAudiobookChapter) => {
|
||||||
|
if (!chapter.bookId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/audio/convert/chapter?bookId=${chapter.bookId}&chapterIndex=${chapter.index}`);
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
// Use the chapter's stored format directly - it knows what it actually is
|
||||||
|
const ext = chapter.format || 'm4b';
|
||||||
|
a.download = `${chapter.title}.${ext}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading chapter:', error);
|
||||||
|
setErrorMessage('Failed to download chapter. Please try again.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownloadComplete = useCallback(async () => {
|
||||||
|
if (!bookId) return;
|
||||||
|
|
||||||
|
setIsCombining(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/audio/convert?bookId=${bookId}&format=${format}`);
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('No response body');
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||||
|
const blob = new Blob(chunks as BlobPart[], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `audiobook.${format}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading complete audiobook:', error);
|
||||||
|
setErrorMessage('Failed to download audiobook. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsCombining(false);
|
||||||
|
}
|
||||||
|
}, [bookId, format]);
|
||||||
|
|
||||||
|
|
||||||
|
const formatDuration = (seconds?: number) => {
|
||||||
|
if (!seconds) return '--:--';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute display list including gaps before the highest existing index
|
||||||
|
const maxIndex = chapters.length > 0 ? Math.max(...chapters.map(c => c.index)) : -1;
|
||||||
|
const displayChapters: TTSAudiobookChapter[] =
|
||||||
|
maxIndex >= 0
|
||||||
|
? Array.from({ length: maxIndex + 1 }, (_, i) => {
|
||||||
|
const existing = chapters.find(c => c.index === i);
|
||||||
|
if (existing) return existing;
|
||||||
|
return {
|
||||||
|
index: i,
|
||||||
|
title: documentType === 'pdf' ? `Page ${i + 1}` : `Chapter ${i + 1}`,
|
||||||
|
status: 'pending',
|
||||||
|
bookId: bookId || undefined,
|
||||||
|
format
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Determine if we should show the Resume and Reset buttons
|
||||||
|
const hasAnyChapters = chapters.length > 0;
|
||||||
|
const showResumeButton = !isGenerating && !regeneratingChapter && hasAnyChapters;
|
||||||
|
const showResetButton = !isGenerating && !regeneratingChapter && hasAnyChapters;
|
||||||
|
|
||||||
|
// Do not render until storage/config is initialized
|
||||||
|
if (isLoading || !isDBReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProgressPopup
|
||||||
|
isOpen={isGenerating && !isOpen}
|
||||||
|
progress={progress}
|
||||||
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
cancelText="Cancel"
|
||||||
|
operationType="audiobook"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
currentChapter={currentChapter}
|
||||||
|
totalChapters={documentType === 'epub' ? undefined : undefined}
|
||||||
|
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel className="w-full max-w-2xl transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||||
|
{isLoadingExisting ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center gap-3">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
|
||||||
|
{!isGenerating && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{chapters.length === 0 && (
|
||||||
|
<Listbox
|
||||||
|
value={format}
|
||||||
|
onChange={(newFormat) => setFormat(newFormat)}
|
||||||
|
disabled={chapters.length > 0}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<ListboxButton className="relative cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[100px]">
|
||||||
|
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||||
|
</span>
|
||||||
|
</ListboxButton>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<ListboxOptions className="absolute right-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
||||||
|
<ListboxOption
|
||||||
|
value="m4b"
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
M4B
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ListboxOption>
|
||||||
|
<ListboxOption
|
||||||
|
value="mp3"
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
MP3
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ListboxOption>
|
||||||
|
</ListboxOptions>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
)}
|
||||||
|
{chapters.length === 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleStartGeneration}
|
||||||
|
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
>
|
||||||
|
Start Generation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showResumeButton && (
|
||||||
|
<Button
|
||||||
|
onClick={handleStartGeneration}
|
||||||
|
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showResetButton && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowResetConfirm(true)}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
|
||||||
|
font-medium text-background hover:bg-red-500/90 focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
title="Delete all generated chapters/pages for this document"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showRegenerateHint && (
|
||||||
|
<div className="flex items-start justify-between bg-offbase border border-offbase rounded-md px-3 py-2 text-xs sm:text-sm">
|
||||||
|
<p className="text-xs sm:text-sm text-foreground">
|
||||||
|
TTS audio for this chapter may be cached
|
||||||
|
<br />
|
||||||
|
Change the TTS playback options or restart the server to force uncached regeneration
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowRegenerateHint(false)}
|
||||||
|
className="ml-3 p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
||||||
|
aria-label="Dismiss regenerate hint"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Progress Info */}
|
||||||
|
{isGenerating && (
|
||||||
|
<ProgressCard
|
||||||
|
progress={progress}
|
||||||
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
operationType="audiobook"
|
||||||
|
currentChapter={currentChapter}
|
||||||
|
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
||||||
|
cancelText="Cancel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chapters.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={`space-y-2 max-h-96 ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`} aria-busy={isRefreshingChapters}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">Chapters</h4>
|
||||||
|
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />}
|
||||||
|
</div>
|
||||||
|
{displayChapters.map((chapter) => (
|
||||||
|
<div
|
||||||
|
key={chapter.index}
|
||||||
|
className={`flex items-center justify-between px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3 flex-1">
|
||||||
|
{chapter.status === 'completed' ? (
|
||||||
|
<CheckCircleIcon className="h-5 w-5 text-accent" />
|
||||||
|
) : onRegenerateChapter ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRegenerateChapter(chapter)}
|
||||||
|
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-offbase text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
|
||||||
|
>
|
||||||
|
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<ClockIcon className="h-5 w-5 text-muted" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{chapter.title}
|
||||||
|
</p>
|
||||||
|
<p>•</p>
|
||||||
|
<p className="text-xs text-muted mt-0.5">
|
||||||
|
{chapter.status !== 'completed' && <span className="text-warning">Missing • </span>}{formatDuration(chapter.duration)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
||||||
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
|
<MenuButton
|
||||||
|
className="inline-flex items-center justify-center rounded-md p-1.5 hover:bg-background focus:outline-none focus-visible:ring-2 focus-visible:ring-accent text-muted hover:text-foreground transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
title="Chapter actions"
|
||||||
|
>
|
||||||
|
<DotsVerticalIcon className="h-5 w-5" />
|
||||||
|
</MenuButton>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<MenuItems className="absolute right-0 bottom-full mb-2 w-44 origin-bottom-right rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-10 p-1">
|
||||||
|
{chapter.status === 'completed' && (
|
||||||
|
<>
|
||||||
|
<MenuItem>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingDeleteChapter(chapter)}
|
||||||
|
className={`${active ? 'bg-offbase' : ''} text-red-500 group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||||
|
title="Delete this chapter"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadChapter(chapter)}
|
||||||
|
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-4 w-4" />
|
||||||
|
<span>Download</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{regeneratingChapter === chapter.index && (
|
||||||
|
<MenuItem>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className={`${active ? 'bg-offbase text-red-500' : 'text-red-500'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||||
|
title="Cancel this chapter regeneration"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onRegenerateChapter && !isGenerating && (
|
||||||
|
<MenuItem disabled={regeneratingChapter !== null}>
|
||||||
|
{({ active, disabled }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRegenerateChapter(chapter)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
title="Regenerate this chapter"
|
||||||
|
>
|
||||||
|
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</MenuItems>
|
||||||
|
{/* end of menu items */}
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookId && !isGenerating && (
|
||||||
|
<div className="pt-4 border-t border-offbase">
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadComplete}
|
||||||
|
disabled={isCombining}
|
||||||
|
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
||||||
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-5 w-5" />
|
||||||
|
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Click "Start Generation" to begin creating your audiobook.
|
||||||
|
<br />
|
||||||
|
Individual chapters will appear here as they are generated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
|
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
{/* Confirm delete chapter */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDeleteChapter !== null}
|
||||||
|
onClose={() => setPendingDeleteChapter(null)}
|
||||||
|
onConfirm={performDeleteChapter}
|
||||||
|
title="Delete Chapter"
|
||||||
|
message={pendingDeleteChapter ? `Delete "${pendingDeleteChapter.title}"? This will remove the audio and metadata for this chapter.` : ''}
|
||||||
|
confirmText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
isDangerous
|
||||||
|
/>
|
||||||
|
{/* Confirm reset all */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showResetConfirm}
|
||||||
|
onClose={() => setShowResetConfirm(false)}
|
||||||
|
onConfirm={performResetAll}
|
||||||
|
title="Reset Audiobook"
|
||||||
|
message="Reset audiobook? This deletes all generated chapters/pages and any combined files. This cannot be undone."
|
||||||
|
confirmText="Reset"
|
||||||
|
cancelText="Cancel"
|
||||||
|
isDangerous
|
||||||
|
/>
|
||||||
|
{/* Error dialog replacing alerts */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={errorMessage !== null}
|
||||||
|
onClose={() => setErrorMessage(null)}
|
||||||
|
onConfirm={() => setErrorMessage(null)}
|
||||||
|
title="Operation Failed"
|
||||||
|
message={errorMessage || ''}
|
||||||
|
confirmText="Close"
|
||||||
|
cancelText=""
|
||||||
|
isDangerous={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ export function ConfirmDialog({
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog
|
<Dialog
|
||||||
as="div"
|
as="div"
|
||||||
|
role={undefined}
|
||||||
className="relative z-50"
|
className="relative z-50"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|
@ -46,7 +47,7 @@ export function ConfirmDialog({
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
@ -60,7 +61,7 @@ export function ConfirmDialog({
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
<DialogPanel role='dialog' className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-semibold leading-6 text-foreground"
|
className="text-lg font-semibold leading-6 text-foreground"
|
||||||
|
|
@ -74,8 +75,8 @@ export function ConfirmDialog({
|
||||||
<div className="mt-6 flex justify-end space-x-3">
|
<div className="mt-6 flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -84,12 +85,12 @@ export function ConfirmDialog({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`inline-flex justify-center rounded-lg px-4 py-2 text-sm
|
className={`inline-flex justify-center rounded-lg px-3 py-1.5 text-sm
|
||||||
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
|
||||||
${isDangerous
|
${isDangerous
|
||||||
? 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 hover:text-white'
|
? 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
|
||||||
: 'bg-accent text-white hover:bg-accent/90 focus-visible:ring-accent hover:text-background'
|
: 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
|
||||||
}`}
|
}`}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,22 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useRef, useCallback, useEffect } from 'react';
|
import { Fragment, useState, useCallback, useEffect } from 'react';
|
||||||
import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
|
import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
|
||||||
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||||
import { useEPUB } from '@/contexts/EPUBContext';
|
import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { usePDF } from '@/contexts/PDFContext';
|
import { usePDF } from '@/contexts/PDFContext';
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
const viewTypes = [
|
const viewTypeTextMapping = [
|
||||||
{ id: 'single', name: 'Single Page' },
|
{ id: 'single', name: 'Single Page' },
|
||||||
{ id: 'dual', name: 'Two Pages' },
|
{ id: 'dual', name: 'Two Pages' },
|
||||||
{ id: 'scroll', name: 'Continuous Scroll' },
|
{ id: 'scroll', name: 'Continuous Scroll' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const audioFormats = [
|
|
||||||
{ id: 'mp3', name: 'MP3' },
|
|
||||||
{ id: 'm4b', name: 'M4B' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
setIsOpen: (isOpen: boolean) => void,
|
setIsOpen: (isOpen: boolean) => void,
|
||||||
|
|
@ -32,25 +27,25 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
viewType,
|
viewType,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
|
smartSentenceSplitting,
|
||||||
headerMargin,
|
headerMargin,
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
rightMargin,
|
rightMargin,
|
||||||
updateConfigKey
|
updateConfigKey,
|
||||||
|
pdfHighlightEnabled,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
const { createFullAudioBook, isAudioCombining } = useEPUB();
|
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
||||||
const { createFullAudioBook: createPDFAudioBook, isAudioCombining: isPDFAudioCombining } = usePDF();
|
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
||||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
const { id } = useParams();
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
const [audioFormat, setAudioFormat] = useState<'mp3' | 'm4b'>('mp3');
|
|
||||||
const [localMargins, setLocalMargins] = useState({
|
const [localMargins, setLocalMargins] = useState({
|
||||||
header: headerMargin,
|
header: headerMargin,
|
||||||
footer: footerMargin,
|
footer: footerMargin,
|
||||||
left: leftMargin,
|
left: leftMargin,
|
||||||
right: rightMargin
|
right: rightMargin
|
||||||
});
|
});
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||||
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||||
|
|
||||||
// Sync local margins with global state
|
// Sync local margins with global state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -79,61 +74,41 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartGeneration = useCallback(async () => {
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
setIsGenerating(true);
|
onProgress: (progress: number) => void,
|
||||||
setProgress(0);
|
signal: AbortSignal,
|
||||||
abortControllerRef.current = new AbortController();
|
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||||
|
format: 'mp3' | 'm4b'
|
||||||
try {
|
) => {
|
||||||
const audioBuffer = epub ? await createFullAudioBook(
|
if (epub) {
|
||||||
(progress) => setProgress(progress),
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
abortControllerRef.current.signal,
|
} else {
|
||||||
audioFormat
|
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
) : await createPDFAudioBook(
|
|
||||||
(progress) => setProgress(progress),
|
|
||||||
abortControllerRef.current.signal,
|
|
||||||
audioFormat
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create and trigger download
|
|
||||||
const mimeType = audioFormat === 'mp3' ? 'audio/mp3' : 'audio/mp4';
|
|
||||||
const blob = new Blob([audioBuffer], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `audiobook.${audioFormat}`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating audiobook:', error);
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
setProgress(0);
|
|
||||||
abortControllerRef.current = null;
|
|
||||||
}
|
}
|
||||||
}, [createFullAudioBook, createPDFAudioBook, epub, audioFormat, setProgress]);
|
}, [epub, createEPUBAudioBook, createPDFAudioBook, id]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleRegenerateChapter = useCallback(async (
|
||||||
if (abortControllerRef.current) {
|
chapterIndex: number,
|
||||||
abortControllerRef.current.abort();
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
signal: AbortSignal
|
||||||
|
) => {
|
||||||
|
if (epub) {
|
||||||
|
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
||||||
|
} else {
|
||||||
|
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
||||||
}
|
}
|
||||||
};
|
}, [epub, regenerateEPUBChapter, regeneratePDFChapter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProgressPopup
|
<AudiobookExportModal
|
||||||
isOpen={isGenerating}
|
isOpen={isAudiobookModalOpen}
|
||||||
progress={progress}
|
setIsOpen={setIsAudiobookModalOpen}
|
||||||
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
documentType={epub ? 'epub' : 'pdf'}
|
||||||
onCancel={handleCancel}
|
documentId={id as string}
|
||||||
isProcessing={epub ? isAudioCombining : isPDFAudioCombining}
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
cancelText="Cancel and download"
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
|
@ -147,7 +122,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
@ -162,44 +137,17 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||||
{isDev && <div className="space-y-2">
|
{isDev && !html && <div className="space-y-2 mb-4">
|
||||||
<div className="flex flex-col space-y-2">
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
disabled={isGenerating}
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||||
font-medium text-background hover:opacity-95 focus:outline-none
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
>
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
Export Audiobook
|
||||||
onClick={handleStartGeneration}
|
</Button>
|
||||||
>
|
|
||||||
Export to {audioFormat.toUpperCase()} (experimental)
|
|
||||||
</Button>
|
|
||||||
<Listbox value={audioFormat} onChange={(format) => setAudioFormat(format as 'mp3' | 'm4b')}>
|
|
||||||
<div className="relative flex self-end">
|
|
||||||
<ListboxButton
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="flex self-end justify-center items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent">
|
|
||||||
<span>{audioFormat === 'mp3' ? 'MP3' : 'M4B (Audiobook)'}</span>
|
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
|
||||||
</ListboxButton>
|
|
||||||
<ListboxOptions anchor='bottom end' className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
|
||||||
{audioFormats.map((format) => (
|
|
||||||
<ListboxOption
|
|
||||||
key={format.id}
|
|
||||||
value={format.id}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="text-xs sm:text-sm">{format.name}</span>
|
|
||||||
</ListboxOption>
|
|
||||||
))}
|
|
||||||
</ListboxOptions>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -299,7 +247,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
>
|
>
|
||||||
<div className="relative z-10 space-y-2">
|
<div className="relative z-10 space-y-2">
|
||||||
<label className="block text-sm font-medium text-foreground">Mode</label>
|
<label className="block text-sm font-medium text-foreground">Mode</label>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
|
||||||
<span className="block truncate">{selectedView.name}</span>
|
<span className="block truncate">{selectedView.name}</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||||
|
|
@ -312,11 +260,11 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
||||||
{viewTypes.map((view) => (
|
{viewTypeTextMapping.map((view) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={view.id}
|
key={view.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={view}
|
value={view}
|
||||||
|
|
@ -361,6 +309,40 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
Automatically skip pages with no text content
|
Automatically skip pages with no text content
|
||||||
</p>
|
</p>
|
||||||
</div>}
|
</div>}
|
||||||
|
{!html && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={smartSentenceSplitting}
|
||||||
|
onChange={(e) => updateConfigKey('smartSentenceSplitting', e.target.checked)}
|
||||||
|
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
Smart sentence splitting
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted pl-6">
|
||||||
|
Merge sentences across page or section breaks for smoother TTS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!epub && !html && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pdfHighlightEnabled}
|
||||||
|
onChange={(e) => updateConfigKey('pdfHighlightEnabled', e.target.checked)}
|
||||||
|
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted pl-6">
|
||||||
|
Show visual highlighting in the PDF viewer while TTS is reading.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{epub && (
|
{epub && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex items-center space-x-2">
|
<label className="flex items-center space-x-2">
|
||||||
|
|
@ -382,8 +364,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent z-1"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent z-1"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
|
||||||
|
|
||||||
interface DocumentUploaderProps {
|
interface DocumentUploaderProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
variant?: 'default' | 'compact';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) {
|
||||||
const {
|
const {
|
||||||
addPDFDocument: addPDF,
|
addPDFDocument: addPDF,
|
||||||
addEPUBDocument: addEPUB,
|
addEPUBDocument: addEPUB,
|
||||||
|
|
@ -87,41 +88,51 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
disabled: isUploading || isConverting
|
disabled: isUploading || isConverting
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const containerBase = `w-full border-2 border-dashed rounded-lg ${isDragActive ? 'border-accent bg-base' : 'border-muted'} transform transition-transform duration-200 ease-in-out ${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base hover:scale-[1.008]'} ${className}`;
|
||||||
|
const paddingClass = variant === 'compact' ? 'py-1.5 px-2' : 'py-5 px-3';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={`
|
className={`${containerBase} ${paddingClass}`}
|
||||||
w-full py-5 px-3 border-2 border-dashed rounded-lg
|
|
||||||
${isDragActive ? 'border-accent bg-base' : 'border-muted'}
|
|
||||||
transform trasition-transform duration-200 ease-in-out hover:scale-[1.008]
|
|
||||||
${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base'}
|
|
||||||
${className}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
{variant === 'compact' ? (
|
||||||
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
<div className="flex items-center gap-2 text-left">
|
||||||
|
<UploadIcon className="w-5 h-5 text-muted" />
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
<p className="text-xs font-medium text-foreground">Uploading…</p>
|
||||||
Uploading file...
|
) : isConverting ? (
|
||||||
</p>
|
<p className="text-xs font-medium text-foreground">Converting DOCX…</p>
|
||||||
) : isConverting ? (
|
) : (
|
||||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
<div className="flex items-center gap-2">
|
||||||
Converting DOCX to PDF...
|
<p className="text-xs font-medium text-foreground">
|
||||||
</p>
|
{isDragActive ? 'Drop files here' : 'Drop files or click'}
|
||||||
) : (
|
</p>
|
||||||
<>
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
|
</div>
|
||||||
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
|
)}
|
||||||
</p>
|
</div>
|
||||||
<p className="text-xs sm:text-sm text-muted">
|
) : (
|
||||||
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
</p>
|
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
||||||
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
{isUploading ? (
|
||||||
</>
|
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
|
||||||
)}
|
) : isConverting ? (
|
||||||
</div>
|
<p className="text-sm sm:text-lg font-semibold text-foreground">Converting DOCX to PDF...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
|
||||||
|
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs sm:text-sm text-muted">
|
||||||
|
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
|
||||||
|
</p>
|
||||||
|
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
|
||||||
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
||||||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||||
|
|
||||||
|
|
@ -65,11 +64,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
|
||||||
<div className="z-10">
|
<div className="flex-1">
|
||||||
<TTSPlayer />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 -mt-16 pt-16">
|
|
||||||
<ReactReader
|
<ReactReader
|
||||||
loadingView={<DocumentSkeleton />}
|
loadingView={<DocumentSkeleton />}
|
||||||
key={'epub-reader'}
|
key={'epub-reader'}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useRef } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useHTML } from '@/contexts/HTMLContext';
|
import { useHTML } from '@/contexts/HTMLContext';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
|
|
||||||
interface HTMLViewerProps {
|
interface HTMLViewerProps {
|
||||||
|
|
@ -20,24 +19,21 @@ export function HTMLViewer({ className = '' }: HTMLViewerProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file is a txt file
|
// Check if the file is a txt file
|
||||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
|
||||||
<div className="z-10">
|
<div className="flex-1 overflow-auto">
|
||||||
<TTSPlayer />
|
<div className={`html-container min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
||||||
</div>
|
{isTxtFile ? (
|
||||||
<div className="flex-1 overflow-auto">
|
currDocData
|
||||||
<div className={`min-w-full px-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
) : (
|
||||||
{isTxtFile ? (
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
currDocData
|
{currDocData}
|
||||||
) : (
|
</ReactMarkdown>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
)}
|
||||||
{currDocData}
|
</div>
|
||||||
</ReactMarkdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
src/components/Header.tsx
Normal file
27
src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
left,
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
left?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base" data-app-header>
|
||||||
|
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{left}
|
||||||
|
{typeof title === 'string' ? (
|
||||||
|
<h1 className="text-xs sm:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/HomeContent.tsx
Normal file
24
src/components/HomeContent.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||||
|
import { DocumentList } from '@/components/doclist/DocumentList';
|
||||||
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
|
|
||||||
|
export function HomeContent() {
|
||||||
|
const { pdfDocs, epubDocs, htmlDocs } = useDocuments();
|
||||||
|
const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0);
|
||||||
|
|
||||||
|
if (totalDocs === 0) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<DocumentUploader className="py-12" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<DocumentList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import { RefObject, useCallback, useState, useEffect, useRef } from 'react';
|
import { RefObject, useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { Document, Page } from 'react-pdf';
|
import { Document, Page } from 'react-pdf';
|
||||||
|
import type { Dest } from 'react-pdf/src/shared/types.js';
|
||||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||||
import 'react-pdf/dist/Page/TextLayer.css';
|
import 'react-pdf/dist/Page/TextLayer.css';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { usePDF } from '@/contexts/PDFContext';
|
import { usePDF } from '@/contexts/PDFContext';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { usePDFResize } from '@/hooks/pdf/usePDFResize';
|
import { usePDFResize } from '@/hooks/pdf/usePDFResize';
|
||||||
|
|
||||||
|
|
@ -15,9 +15,9 @@ interface PDFViewerProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OnItemClickArgs {
|
interface PDFOnLinkClickArgs {
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
dest?: unknown;
|
dest?: Dest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
|
|
@ -26,13 +26,11 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
const { containerWidth } = usePDFResize(containerRef);
|
const { containerWidth } = usePDFResize(containerRef);
|
||||||
|
|
||||||
// Config context
|
// Config context
|
||||||
const { viewType } = useConfig();
|
const { viewType, pdfHighlightEnabled } = useConfig();
|
||||||
|
|
||||||
// TTS context
|
// TTS context
|
||||||
const {
|
const {
|
||||||
currentSentence,
|
currentSentence,
|
||||||
stopAndPlayFromIndex,
|
|
||||||
isProcessing,
|
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
|
|
||||||
|
|
@ -40,7 +38,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
const {
|
const {
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
handleTextClick,
|
|
||||||
onDocumentLoadSuccess,
|
onDocumentLoadSuccess,
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
|
|
@ -48,53 +45,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
currDocPage,
|
currDocPage,
|
||||||
} = usePDF();
|
} = usePDF();
|
||||||
|
|
||||||
// Add static styles once during component initialization
|
|
||||||
const styleElement = document.createElement('style');
|
|
||||||
styleElement.textContent = `
|
|
||||||
.react-pdf__Page__textContent span {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
.react-pdf__Page__textContent span:hover {
|
|
||||||
background-color: rgba(255, 255, 0, 0.2) !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(styleElement);
|
|
||||||
|
|
||||||
// Cleanup styles when component unmounts
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
styleElement.remove();
|
|
||||||
};
|
|
||||||
}, [styleElement]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
/*
|
|
||||||
* Sets up click event listeners for text selection in the PDF.
|
|
||||||
* Cleans up by removing the event listener when component unmounts.
|
|
||||||
*
|
|
||||||
* Dependencies:
|
|
||||||
* - pdfText: Re-run when the extracted text content changes
|
|
||||||
* - handleTextClick: Function from context that could change
|
|
||||||
* - stopAndPlayFromIndex: Function from context that could change
|
|
||||||
*/
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
if (!currDocText) return;
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => handleTextClick(
|
|
||||||
event,
|
|
||||||
currDocText,
|
|
||||||
containerRef as RefObject<HTMLDivElement>,
|
|
||||||
stopAndPlayFromIndex,
|
|
||||||
isProcessing
|
|
||||||
);
|
|
||||||
container.addEventListener('click', handleClick);
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('click', handleClick);
|
|
||||||
};
|
|
||||||
}, [currDocText, handleTextClick, stopAndPlayFromIndex, isProcessing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
* Handles highlighting the current sentence being read by TTS.
|
* Handles highlighting the current sentence being read by TTS.
|
||||||
|
|
@ -106,7 +56,11 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
* - highlightPattern: Function from context that could change
|
* - highlightPattern: Function from context that could change
|
||||||
* - clearHighlights: Function from context that could change
|
* - clearHighlights: Function from context that could change
|
||||||
*/
|
*/
|
||||||
if (!currDocText) return;
|
|
||||||
|
if (!currDocText || !pdfHighlightEnabled) {
|
||||||
|
clearHighlights();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const highlightTimeout = setTimeout(() => {
|
const highlightTimeout = setTimeout(() => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
|
|
@ -118,7 +72,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
clearTimeout(highlightTimeout);
|
clearTimeout(highlightTimeout);
|
||||||
clearHighlights();
|
clearHighlights();
|
||||||
};
|
};
|
||||||
}, [currDocText, currentSentence, highlightPattern, clearHighlights]);
|
}, [currDocText, currentSentence, highlightPattern, clearHighlights, pdfHighlightEnabled]);
|
||||||
|
|
||||||
// Add page dimensions state
|
// Add page dimensions state
|
||||||
const [pageWidth, setPageWidth] = useState<number>(595); // default A4 width
|
const [pageWidth, setPageWidth] = useState<number>(595); // default A4 width
|
||||||
|
|
@ -135,7 +89,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
// Modify scale calculation to be more efficient
|
// Modify scale calculation to be more efficient
|
||||||
const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => {
|
const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => {
|
||||||
const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type
|
const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type
|
||||||
const containerHeight = window.innerHeight - 100;
|
const containerHeight = (containerRef.current?.clientHeight ?? window.innerHeight);
|
||||||
const targetWidth = viewType === 'dual'
|
const targetWidth = viewType === 'dual'
|
||||||
? (containerWidth - margin) / 2 // divide by 2 for dual pages
|
? (containerWidth - margin) / 2 // divide by 2 for dual pages
|
||||||
: containerWidth - margin;
|
: containerWidth - margin;
|
||||||
|
|
@ -165,7 +119,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
}, [calculateScale]);
|
}, [calculateScale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
|
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
|
||||||
<Document
|
<Document
|
||||||
loading={<DocumentSkeleton />}
|
loading={<DocumentSkeleton />}
|
||||||
noData={<DocumentSkeleton />}
|
noData={<DocumentSkeleton />}
|
||||||
|
|
@ -173,12 +127,12 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
onLoadSuccess={(pdf) => {
|
onLoadSuccess={(pdf) => {
|
||||||
onDocumentLoadSuccess(pdf);
|
onDocumentLoadSuccess(pdf);
|
||||||
}}
|
}}
|
||||||
onItemClick={(args: OnItemClickArgs) => {
|
onItemClick={(args: PDFOnLinkClickArgs) => {
|
||||||
if (args?.pageNumber) {
|
if (args?.pageNumber) {
|
||||||
skipToLocation(args.pageNumber, true);
|
skipToLocation(args.pageNumber, true);
|
||||||
} else if (args?.dest) {
|
} else if (args?.dest) {
|
||||||
const destArray = Array.isArray(args.dest) ? args.dest : [];
|
const destArray = args.dest as Array<number> || [];
|
||||||
const pageNum = typeof destArray[0] === 'number' ? destArray[0] + 1 : undefined;
|
const pageNum = destArray[0] + 1 || null;
|
||||||
if (pageNum) {
|
if (pageNum) {
|
||||||
skipToLocation(pageNum, true);
|
skipToLocation(pageNum, true);
|
||||||
}
|
}
|
||||||
|
|
@ -240,10 +194,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Document>
|
</Document>
|
||||||
<TTSPlayer
|
|
||||||
currentPage={currDocPage}
|
|
||||||
numPages={currDocPages}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
87
src/components/ProgressCard.tsx
Normal file
87
src/components/ProgressCard.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
interface ProgressCardProps {
|
||||||
|
progress: number;
|
||||||
|
estimatedTimeRemaining?: string;
|
||||||
|
onCancel: (e?: React.MouseEvent) => void;
|
||||||
|
operationType?: 'sync' | 'load' | 'audiobook';
|
||||||
|
cancelText?: string;
|
||||||
|
currentChapter?: string;
|
||||||
|
completedChapters?: number;
|
||||||
|
statusMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressCard({
|
||||||
|
progress,
|
||||||
|
estimatedTimeRemaining,
|
||||||
|
onCancel,
|
||||||
|
operationType,
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
currentChapter,
|
||||||
|
completedChapters,
|
||||||
|
statusMessage
|
||||||
|
}: ProgressCardProps) {
|
||||||
|
const getOperationLabel = () => {
|
||||||
|
if (operationType === 'sync') return 'Saving to Server';
|
||||||
|
if (operationType === 'load') return 'Loading from Server';
|
||||||
|
if (operationType === 'audiobook') return 'Generating Audiobook';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const operationLabel = getOperationLabel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-offbase rounded-lg p-3 space-y-2">
|
||||||
|
{/* Header with operation type and cancel button */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
{operationLabel && (
|
||||||
|
<div className="text-accent font-semibold text-xs uppercase tracking-wide">
|
||||||
|
{operationLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{statusMessage && (
|
||||||
|
<div className="text-sm font-medium text-foreground truncate" title={statusMessage}>
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentChapter && (
|
||||||
|
<div className="text-sm font-medium text-foreground truncate" title={currentChapter}>
|
||||||
|
{currentChapter}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors"
|
||||||
|
onClick={(e) => onCancel(e)}
|
||||||
|
>
|
||||||
|
<span>{cancelText}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-background rounded-full overflow-hidden h-1.5">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
|
{completedChapters !== undefined && (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">{completedChapters} chapters</span>
|
||||||
|
<span>•</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="font-medium">{Math.round(progress)}%</span>
|
||||||
|
{estimatedTimeRemaining && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{estimatedTimeRemaining}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { LoadingSpinner } from './Spinner';
|
import { ProgressCard } from './ProgressCard';
|
||||||
|
|
||||||
interface ProgressPopupProps {
|
interface ProgressPopupProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
progress: number;
|
progress: number;
|
||||||
estimatedTimeRemaining?: string;
|
estimatedTimeRemaining?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isProcessing: boolean;
|
|
||||||
statusMessage?: string;
|
statusMessage?: string;
|
||||||
operationType?: 'sync' | 'load';
|
operationType?: 'sync' | 'load' | 'audiobook';
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
currentChapter?: string;
|
||||||
|
totalChapters?: number;
|
||||||
|
completedChapters?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCancel, isProcessing, statusMessage, operationType, cancelText = 'Cancel' }: ProgressPopupProps) {
|
export function ProgressPopup({
|
||||||
|
isOpen,
|
||||||
|
progress,
|
||||||
|
estimatedTimeRemaining,
|
||||||
|
onCancel,
|
||||||
|
statusMessage,
|
||||||
|
operationType,
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
onClick,
|
||||||
|
currentChapter,
|
||||||
|
completedChapters
|
||||||
|
}: ProgressPopupProps) {
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
show={isOpen}
|
show={isOpen}
|
||||||
|
|
@ -25,46 +39,27 @@ export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCanc
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
leaveTo="opacity-0 -translate-y-4"
|
leaveTo="opacity-0 -translate-y-4"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none">
|
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none px-4">
|
||||||
<div className="w-full max-w-sm mx-auto">
|
<div className="w-full max-w-md mx-auto">
|
||||||
<div className="w-full transform rounded-lg bg-offbase p-4 shadow-xl pointer-events-auto">
|
<div
|
||||||
<div className="space-y-2 truncate">
|
className={`pointer-events-auto shadow-xl ${
|
||||||
<div className="w-full bg-background rounded-lg overflow-hidden">
|
onClick ? 'cursor-pointer' : ''
|
||||||
<div
|
}`}
|
||||||
className="h-2 bg-accent transition-all duration-300 ease-in-out"
|
onClick={onClick}
|
||||||
style={{ width: `${progress}%` }}
|
>
|
||||||
/>
|
<ProgressCard
|
||||||
</div>
|
progress={progress}
|
||||||
<div className="flex justify-between items-center text-sm text-muted text-xs sm:text-sm">
|
estimatedTimeRemaining={estimatedTimeRemaining}
|
||||||
<div className="flex flex-col gap-1">
|
onCancel={(e) => {
|
||||||
{operationType && (
|
e?.stopPropagation();
|
||||||
<span className="text-accent font-semibold text-xs uppercase tracking-wide">
|
onCancel();
|
||||||
{operationType === 'sync' ? 'Saving to Server' : 'Loading from Server'}
|
}}
|
||||||
</span>
|
operationType={operationType}
|
||||||
)}
|
cancelText={cancelText}
|
||||||
{statusMessage && <span className="text-foreground font-medium">{statusMessage}</span>}
|
currentChapter={currentChapter}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
completedChapters={completedChapters}
|
||||||
<span>{Math.round(progress)}% complete</span>
|
statusMessage={statusMessage}
|
||||||
{estimatedTimeRemaining && <div>
|
/>
|
||||||
<span>•</span>
|
|
||||||
<span>{` ~${estimatedTimeRemaining}`}</span>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center gap-1 rounded-lg px-2.5 py-1 font-medium text-foreground hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
{isProcessing && (
|
|
||||||
<span className="relative inline-block w-4 h-4">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{cancelText}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,8 @@ import {
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
|
||||||
import { indexedDBService } from '@/utils/indexedDB';
|
import { syncDocumentsToServer, loadDocumentsFromServer, getFirstVisit, setFirstVisit } from '@/lib/dexie';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { setItem, getItem } from '@/utils/indexedDB';
|
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
|
|
@ -42,7 +41,7 @@ export function SettingsModal() {
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
|
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
|
||||||
const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments();
|
const { clearPDFs, clearEPUBs, clearHTML } = useDocuments();
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||||
const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider);
|
const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider);
|
||||||
|
|
@ -123,9 +122,9 @@ export function SettingsModal() {
|
||||||
// set firstVisit on initial load
|
// set firstVisit on initial load
|
||||||
const checkFirstVist = useCallback(async () => {
|
const checkFirstVist = useCallback(async () => {
|
||||||
if (!isDev) return;
|
if (!isDev) return;
|
||||||
const firstVisit = await getItem('firstVisit');
|
const firstVisit = await getFirstVisit();
|
||||||
if (firstVisit == null) {
|
if (!firstVisit) {
|
||||||
await setItem('firstVisit', 'true');
|
await setFirstVisit(true);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
}, [setIsOpen]);
|
}, [setIsOpen]);
|
||||||
|
|
@ -158,7 +157,7 @@ export function SettingsModal() {
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setOperationType('sync');
|
setOperationType('sync');
|
||||||
setStatusMessage('Preparing documents...');
|
setStatusMessage('Preparing documents...');
|
||||||
await indexedDBService.syncToServer((progress, status) => {
|
await syncDocumentsToServer((progress, status) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setProgress(progress);
|
setProgress(progress);
|
||||||
if (status) setStatusMessage(status);
|
if (status) setStatusMessage(status);
|
||||||
|
|
@ -190,14 +189,13 @@ export function SettingsModal() {
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setOperationType('load');
|
setOperationType('load');
|
||||||
setStatusMessage('Downloading documents from server...');
|
setStatusMessage('Downloading documents from server...');
|
||||||
await indexedDBService.loadFromServer((progress, status) => {
|
await loadDocumentsFromServer((progress, status) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setProgress(progress);
|
setProgress(progress);
|
||||||
if (status) setStatusMessage(status);
|
if (status) setStatusMessage(status);
|
||||||
}, controller.signal);
|
}, controller.signal);
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setStatusMessage('Refreshing document list...');
|
setStatusMessage('Documents loaded from server');
|
||||||
await Promise.all([refreshPDFs(), refreshEPUBs()]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
console.log('Load operation cancelled');
|
console.log('Load operation cancelled');
|
||||||
|
|
@ -218,6 +216,7 @@ export function SettingsModal() {
|
||||||
const handleClearLocal = async () => {
|
const handleClearLocal = async () => {
|
||||||
await clearPDFs();
|
await clearPDFs();
|
||||||
await clearEPUBs();
|
await clearEPUBs();
|
||||||
|
await clearHTML();
|
||||||
setShowClearLocalConfirm(false);
|
setShowClearLocalConfirm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -267,11 +266,11 @@ export function SettingsModal() {
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent absolute top-1 left-1 sm:top-3 sm:left-3"
|
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent absolute top-2 right-2 sm:top-4 sm:right-4"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 hover:animate-spin-slow" />
|
<SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 transform transition-transform duration-200 ease-in-out hover:rotate-45" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
|
@ -285,7 +284,7 @@ export function SettingsModal() {
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
@ -316,8 +315,8 @@ export function SettingsModal() {
|
||||||
`w-full rounded-lg py-1 text-sm font-medium
|
`w-full rounded-lg py-1 text-sm font-medium
|
||||||
ring-accent/60 ring-offset-2 ring-offset-base
|
ring-accent/60 ring-offset-2 ring-offset-base
|
||||||
${selected
|
${selected
|
||||||
? 'bg-accent text-white shadow'
|
? 'bg-accent text-background shadow'
|
||||||
: 'text-foreground hover:bg-accent/[0.12] hover:text-accent'
|
: 'text-foreground hover:text-accent'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -329,8 +328,8 @@ export function SettingsModal() {
|
||||||
))}
|
))}
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels className="mt-2">
|
<TabPanels className="mt-2">
|
||||||
<TabPanel className="space-y-4">
|
<TabPanel className="space-y-2.5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
|
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
|
||||||
<Listbox
|
<Listbox
|
||||||
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
|
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
|
||||||
|
|
@ -351,7 +350,7 @@ export function SettingsModal() {
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
|
||||||
<span className="block truncate">
|
<span className="block truncate">
|
||||||
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
|
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -370,8 +369,8 @@ export function SettingsModal() {
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
|
||||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={provider}
|
value={provider}
|
||||||
|
|
@ -395,7 +394,7 @@ export function SettingsModal() {
|
||||||
</Listbox>
|
</Listbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
API Key
|
API Key
|
||||||
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||||
|
|
@ -406,12 +405,12 @@ export function SettingsModal() {
|
||||||
value={localApiKey}
|
value={localApiKey}
|
||||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||||
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"}
|
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"}
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Listbox
|
<Listbox
|
||||||
|
|
@ -426,7 +425,7 @@ export function SettingsModal() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
|
||||||
<span className="block truncate">
|
<span className="block truncate">
|
||||||
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
|
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -445,8 +444,8 @@ export function SettingsModal() {
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
|
||||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={model}
|
value={model}
|
||||||
|
|
@ -478,26 +477,26 @@ export function SettingsModal() {
|
||||||
setModelValue(e.target.value);
|
setModelValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Enter custom model name"
|
placeholder="Enter custom model name"
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modelValue === 'gpt-4o-mini-tts' && (
|
{modelValue === 'gpt-4o-mini-tts' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={localTTSInstructions}
|
value={localTTSInstructions}
|
||||||
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
||||||
placeholder="Enter instructions for the TTS model"
|
placeholder="Enter instructions for the TTS model"
|
||||||
className="w-full h-24 rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full h-24 rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
|
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
API Base URL
|
API Base URL
|
||||||
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||||
|
|
@ -508,17 +507,17 @@ export function SettingsModal() {
|
||||||
value={localBaseUrl}
|
value={localBaseUrl}
|
||||||
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
||||||
placeholder="Using environment variable"
|
placeholder="Using environment variable"
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="pt-4 flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|
@ -534,8 +533,8 @@ export function SettingsModal() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
font-medium text-white hover:bg-accent/90 focus:outline-none
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
|
|
@ -557,10 +556,10 @@ export function SettingsModal() {
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel className="space-y-4 pb-3">
|
<TabPanel className="space-y-4 pb-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">Theme</label>
|
<label className="block text-sm font-medium text-foreground">Theme</label>
|
||||||
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
|
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
|
||||||
<span className="block truncate">{selectedTheme.name}</span>
|
<span className="block truncate">{selectedTheme.name}</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||||
|
|
@ -572,12 +571,12 @@ export function SettingsModal() {
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<ListboxOptions className="absolute mt-1 max-h-40 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
||||||
{themes.map((theme) => (
|
{themes.map((theme) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={theme.id}
|
key={theme.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={theme}
|
value={theme}
|
||||||
|
|
@ -603,14 +602,14 @@ export function SettingsModal() {
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel className="space-y-4">
|
<TabPanel className="space-y-4">
|
||||||
{isDev && <div className="space-y-2">
|
{isDev && <div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">Document Sync</label>
|
<label className="block text-sm font-medium text-foreground">Document Sync</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLoad}
|
onClick={handleLoad}
|
||||||
disabled={isSyncing || isLoading}
|
disabled={isSyncing || isLoading}
|
||||||
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
|
|
@ -621,7 +620,7 @@ export function SettingsModal() {
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={isSyncing || isLoading}
|
disabled={isSyncing || isLoading}
|
||||||
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
|
|
@ -631,23 +630,23 @@ export function SettingsModal() {
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="space-y-2 pb-3">
|
<div className="space-y-1 pb-3">
|
||||||
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
|
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowClearLocalConfirm(true)}
|
onClick={() => setShowClearLocalConfirm(true)}
|
||||||
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
|
||||||
font-medium text-white hover:bg-red-700 focus:outline-none
|
font-medium text-background hover:bg-red-500/90 focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
>
|
>
|
||||||
Delete local docs
|
Delete local docs
|
||||||
</Button>
|
</Button>
|
||||||
{isDev && <Button
|
{isDev && <Button
|
||||||
onClick={() => setShowClearServerConfirm(true)}
|
onClick={() => setShowClearServerConfirm(true)}
|
||||||
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
|
||||||
font-medium text-white hover:bg-red-700 focus:outline-none
|
font-medium text-background hover:bg-red-500/90 focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
>
|
>
|
||||||
Delete server docs
|
Delete server docs
|
||||||
|
|
@ -700,7 +699,6 @@ export function SettingsModal() {
|
||||||
setOperationType('sync');
|
setOperationType('sync');
|
||||||
setAbortController(null);
|
setAbortController(null);
|
||||||
}}
|
}}
|
||||||
isProcessing={isSyncing || isLoading}
|
|
||||||
statusMessage={statusMessage}
|
statusMessage={statusMessage}
|
||||||
operationType={operationType}
|
operationType={operationType}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
|
|
|
||||||
37
src/components/ZoomControl.tsx
Normal file
37
src/components/ZoomControl.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
export function ZoomControl({
|
||||||
|
value,
|
||||||
|
onIncrease,
|
||||||
|
onDecrease,
|
||||||
|
min = 50,
|
||||||
|
max = 300,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onIncrease: () => void;
|
||||||
|
onDecrease: () => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 select-none" aria-label="Zoom controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDecrease}
|
||||||
|
disabled={value <= min}
|
||||||
|
className="px-1 text-sm leading-none text-foreground hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.09]"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="text-xs tabular-nums w-12 text-center text-muted">{value}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onIncrease}
|
||||||
|
disabled={value >= max}
|
||||||
|
className="px-1 text-sm leading-none text-foreground hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.09]"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ export function CreateFolderDialog({
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, DragEvent } from 'react';
|
import { useState, DragEvent } from 'react';
|
||||||
import { Button, Transition } from '@headlessui/react';
|
import { Button, Transition } from '@headlessui/react';
|
||||||
import { DocumentListItem } from './DocumentListItem';
|
import { DocumentListItem } from './DocumentListItem';
|
||||||
|
|
@ -16,7 +18,7 @@ interface DocumentFolderProps {
|
||||||
onDrop: (e: DragEvent, folderId: string) => void;
|
onDrop: (e: DragEvent, folderId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChevronIcon = ({ className = "w-5 h-5" }) => (
|
const ChevronIcon = ({ className = "w-4 h-4" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -62,36 +64,37 @@ export function DocumentFolder({
|
||||||
if (!draggedDoc || draggedDoc.folderId) return;
|
if (!draggedDoc || draggedDoc.folderId) return;
|
||||||
onDrop(e, folder.id);
|
onDrop(e, folder.id);
|
||||||
}}
|
}}
|
||||||
className={`overflow-hidden rounded-lg transition-all bg-offbase shadow hover:shadow-md ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
|
className={`overflow-hidden rounded-md border border-offbase ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
|
||||||
>
|
>
|
||||||
<div className='flex flex-row justify-between p-2'>
|
<div className='flex flex-row justify-between p-0'>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className={`flex items-center justify-between ${isCollapsed ? 'mb-0' : 'mb-2'} transition-all duration-200`}>
|
<div className={`flex items-center justify-between px-2 py-1 bg-offbase rounded-t-md border-b border-offbase transition-all duration-200`}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<h3 className="text-lg px-1 font-semibold">{folder.name}</h3>
|
<h3 className="text-sm px-1 font-semibold leading-tight">{folder.name}</h3>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onToggleCollapse(folder.id)}
|
type="button"
|
||||||
className="transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:font-semibold hover:text-accent"
|
onClick={() => onToggleCollapse(folder.id)}
|
||||||
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
className="ml-0.5 p-1 inline-flex items-center justify-center transform transition-transform duration-200 ease-in-out hover:scale-[1.08] hover:text-accent"
|
||||||
|
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
aria-controls={`folder-panel-${folder.id}`}
|
||||||
|
title={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||||
>
|
>
|
||||||
<ChevronIcon
|
<ChevronIcon className={`w-4 h-4 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
|
||||||
className={`w-5 h-5 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="p-1 text-muted hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-1 text-muted hover:text-accent hover:bg-base rounded-md transition-colors"
|
||||||
aria-label="Delete folder"
|
aria-label="Delete folder"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4"
|
<svg className="w-3.5 h-3.5"
|
||||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative bg-base px-1 py-1 rounded-b-md">
|
||||||
<div className="relative">
|
|
||||||
<Transition
|
<Transition
|
||||||
show={!isCollapsed}
|
show={!isCollapsed}
|
||||||
enter="transition-all duration-300 ease-out"
|
enter="transition-all duration-300 ease-out"
|
||||||
|
|
@ -101,7 +104,7 @@ export function DocumentFolder({
|
||||||
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||||
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
||||||
>
|
>
|
||||||
<div className="space-y-2 origin-top">
|
<div id={`folder-panel-${folder.id}`} className="space-y-1 origin-top">
|
||||||
{sortedDocuments.map(doc => (
|
{sortedDocuments.map(doc => (
|
||||||
<DocumentListItem
|
<DocumentListItem
|
||||||
key={`${doc.type}-${doc.id}`}
|
key={`${doc.type}-${doc.id}`}
|
||||||
|
|
@ -125,7 +128,7 @@ export function DocumentFolder({
|
||||||
leaveFrom="max-h-[50px] opacity-100"
|
leaveFrom="max-h-[50px] opacity-100"
|
||||||
leaveTo="max-h-0 opacity-0"
|
leaveTo="max-h-0 opacity-0"
|
||||||
>
|
>
|
||||||
<p className="text-xs px-1 text-left text-muted">
|
<p className="text-[10px] px-1 text-left text-muted leading-tight">
|
||||||
{(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
|
{(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
|
||||||
{` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
|
{` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents';
|
import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents';
|
||||||
import { getDocumentListState, saveDocumentListState } from '@/utils/indexedDB';
|
import { getDocumentListState, saveDocumentListState } from '@/lib/dexie';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { DocumentListItem } from '@/components/doclist/DocumentListItem';
|
import { DocumentListItem } from '@/components/doclist/DocumentListItem';
|
||||||
import { DocumentFolder } from '@/components/doclist/DocumentFolder';
|
import { DocumentFolder } from '@/components/doclist/DocumentFolder';
|
||||||
import { SortControls } from '@/components/doclist/SortControls';
|
import { SortControls } from '@/components/doclist/SortControls';
|
||||||
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
||||||
import { Button } from '@headlessui/react';
|
import { Button } from '@headlessui/react';
|
||||||
|
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||||
|
|
||||||
type DocumentToDelete = {
|
type DocumentToDelete = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -297,11 +298,18 @@ export function DocumentList() {
|
||||||
doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
|
doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build compact summary (counts per type + total size)
|
||||||
|
const summaryParts: string[] = [];
|
||||||
|
if (pdfDocs.length) summaryParts.push(`${pdfDocs.length} PDF${pdfDocs.length === 1 ? '' : 's'}`);
|
||||||
|
if (epubDocs.length) summaryParts.push(`${epubDocs.length} EPUB${epubDocs.length === 1 ? '' : 's'}`);
|
||||||
|
if (htmlDocs.length) summaryParts.push(`${htmlDocs.length} HTML${htmlDocs.length === 1 ? '' : 's'}`);
|
||||||
|
const totalSizeMB = (allDocuments.reduce((acc, d) => acc + d.size, 0) / 1024 / 1024).toFixed(2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="w-full mx-auto">
|
<div className="w-full mx-auto">
|
||||||
<div className="flex items-center justify-between mb-2 sm:mb-4">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Your Documents</h2>
|
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Local Documents</h2>
|
||||||
<SortControls
|
<SortControls
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
|
|
@ -309,14 +317,21 @@ export function DocumentList() {
|
||||||
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted mb-2" data-doc-summary>
|
||||||
|
{summaryParts.join(' • ')}{summaryParts.length ? ' • ' : ''}{totalSizeMB} MB total
|
||||||
|
</p>
|
||||||
|
<div className="mb-3">
|
||||||
|
<DocumentUploader variant="compact" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{showHint && allDocuments.length > 1 && (
|
{showHint && allDocuments.length > 1 && (
|
||||||
<div className="flex items-center justify-between bg-background rounded-lg px-3 py-2 text-sm shadow hover:shadow-md transition-shadow">
|
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm">
|
||||||
<p className="text-sm">Drag files on top of each other to make folders</p>
|
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowHint(false)}
|
onClick={() => setShowHint(false)}
|
||||||
className="p-1 hover:bg-accent rounded-lg transition-colors"
|
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
||||||
aria-label="Dismiss hint"
|
aria-label="Dismiss hint"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { DragEvent, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button } from '@headlessui/react';
|
import { Button } from '@headlessui/react';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
|
||||||
import { DocumentListDocument } from '@/types/documents';
|
import { DocumentListDocument } from '@/types/documents';
|
||||||
|
|
||||||
interface DocumentListItemProps {
|
interface DocumentListItemProps {
|
||||||
|
|
@ -50,41 +49,44 @@ export function DocumentListItem({
|
||||||
onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)}
|
onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)}
|
||||||
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
||||||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||||
|
aria-busy={loading}
|
||||||
className={`
|
className={`
|
||||||
w-full
|
w-full group
|
||||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent bg-primary/10' : ''}
|
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
||||||
bg-background rounded-lg p-2 shadow hover:shadow-md transition-shadow
|
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
||||||
relative
|
border border-offbase rounded-md p-1
|
||||||
|
transition-colors duration-150 relative
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{loading && (
|
<div className="flex items-center">
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/80 rounded-lg">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center rounded-lg">
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className="document-link flex items-center align-center space-x-4 w-full truncate hover:bg-base rounded-lg p-0.5 sm:p-1 transition-colors"
|
className="document-link flex items-center align-center gap-2 w-full truncate rounded-md py-0.5 px-0.5"
|
||||||
onClick={handleDocumentClick}
|
onClick={handleDocumentClick}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{doc.type === 'pdf' ? <PDFIcon /> : doc.type === 'epub' ? <EPUBIcon /> : <FileIcon />}
|
{doc.type === 'pdf' ? (
|
||||||
|
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
|
||||||
|
) : doc.type === 'epub' ? (
|
||||||
|
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
|
<div className="flex flex-col min-w-0 transform transition-transform duration-150 ease-in-out hover:scale-[1.009] w-full truncate">
|
||||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
<p className="text-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">{doc.name}</p>
|
||||||
<p className="text-xs sm:text-sm text-muted truncate">
|
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
|
||||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onDelete(doc)}
|
onClick={() => onDelete(doc)}
|
||||||
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
|
className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors"
|
||||||
aria-label="Delete document"
|
aria-label="Delete document"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,13 @@ export function SortControls({
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
onClick={onSortDirectionChange}
|
onClick={onSortDirectionChange}
|
||||||
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap"
|
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
>
|
>
|
||||||
{directionLabel}
|
{directionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||||
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||||
</ListboxButton>
|
</ListboxButton>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
export const PlayIcon = () => (
|
export const PlayIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width={props.width || 24}
|
||||||
height="32"
|
height={props.height || 24}
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
cx="16"
|
cx="16"
|
||||||
|
|
@ -22,13 +24,15 @@ export const PlayIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const PauseIcon = () => (
|
export const PauseIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width={props.width || 24}
|
||||||
height="32"
|
height={props.height || 24}
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
cx="16"
|
cx="16"
|
||||||
|
|
@ -59,13 +63,15 @@ export const PauseIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SkipForwardIcon = () => (
|
export const SkipForwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width={props.width || 24}
|
||||||
height="24"
|
height={props.height || 24}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M6 4l10 8-10 8V4z"
|
d="M6 4l10 8-10 8V4z"
|
||||||
|
|
@ -84,13 +90,15 @@ export const SkipForwardIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SkipBackwardIcon = () => (
|
export const SkipBackwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width={props.width || 24}
|
||||||
height="24"
|
height={props.height || 24}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M18 4L8 12l10 8V4z"
|
d="M18 4L8 12l10 8V4z"
|
||||||
|
|
@ -262,3 +270,147 @@ export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckCircleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function XCircleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClockIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zM12.75 6a.75.75 0 00-1.5 0v6c0 .414.336.75.75.75h4.5a.75.75 0 000-1.5h-3.75V6z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="5" r="1.5" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" />
|
||||||
|
<circle cx="12" cy="19" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"></path>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioWaveIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M6 9.85986V14.1499" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M9 8.42993V15.5699" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M12 7V17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M15 8.42993V15.5699" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M18 9.85986V14.1499" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
<Button
|
<Button
|
||||||
onClick={() => skipToLocation(currentPage - 1, true)}
|
onClick={() => skipToLocation(currentPage - 1, true)}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
|
@ -82,7 +82,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase/80"
|
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
onClick={handlePopoverOpen}
|
onClick={handlePopoverOpen}
|
||||||
>
|
>
|
||||||
<p className="text-xs whitespace-nowrap">
|
<p className="text-xs whitespace-nowrap">
|
||||||
|
|
@ -105,7 +105,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
placeholder={currentPage.toString()}
|
placeholder={currentPage.toString()}
|
||||||
aria-label="Page number"
|
aria-label="Page number"
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-foreground/70 text-center">of {numPages || 1}</div>
|
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</>
|
</>
|
||||||
|
|
@ -117,7 +117,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
<Button
|
<Button
|
||||||
onClick={() => skipToLocation(currentPage + 1, true)}
|
onClick={() => skipToLocation(currentPage + 1, true)}
|
||||||
disabled={currentPage >= (numPages || 1)}
|
disabled={currentPage >= (numPages || 1)}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -54,7 +54,8 @@ export const SpeedControl = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative">
|
<Popover className="relative">
|
||||||
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||||
|
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
|
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
|
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||||
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100 border border-offbase">
|
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
|
||||||
{/* Speed control */}
|
{/* Speed control */}
|
||||||
<SpeedControl
|
<SpeedControl
|
||||||
setSpeedAndRestart={setSpeedAndRestart}
|
setSpeedAndRestart={setSpeedAndRestart}
|
||||||
|
|
@ -51,29 +51,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
||||||
{/* Playback Controls */}
|
{/* Playback Controls */}
|
||||||
<Button
|
<Button
|
||||||
onClick={skipBackward}
|
onClick={skipBackward}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Skip backward"
|
aria-label="Skip backward"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
|
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
|
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={skipForward}
|
onClick={skipForward}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Skip forward"
|
aria-label="Skip forward"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
|
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Voice control */}
|
{/* Voice control */}
|
||||||
|
|
|
||||||
|
|
@ -6,41 +6,126 @@ import {
|
||||||
ListboxOption,
|
ListboxOption,
|
||||||
ListboxOptions,
|
ListboxOptions,
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { parseKokoroVoiceNames, buildKokoroVoiceString, isKokoroModel, getMaxVoicesForProvider } from '@/utils/voice';
|
||||||
|
|
||||||
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { voice: configVoice } = useConfig();
|
const { voice: configVoice, ttsModel, ttsProvider } = useConfig();
|
||||||
|
|
||||||
// If the saved voice is not in the available list, use the first available voice
|
const isKokoro = isKokoroModel(ttsModel);
|
||||||
const currentVoice = (configVoice && availableVoices.includes(configVoice))
|
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
|
||||||
? configVoice
|
|
||||||
: availableVoices[0] || '';
|
// Local selection state for Kokoro multi-select
|
||||||
|
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(isKokoro && maxVoices > 1)) return;
|
||||||
|
let initial: string[] = [];
|
||||||
|
if (configVoice && configVoice.includes('+')) {
|
||||||
|
initial = parseKokoroVoiceNames(configVoice);
|
||||||
|
} else if (configVoice && availableVoices.includes(configVoice)) {
|
||||||
|
initial = [configVoice];
|
||||||
|
} else if (availableVoices.length > 0) {
|
||||||
|
initial = [availableVoices[0]];
|
||||||
|
}
|
||||||
|
// Clamp to provider limit
|
||||||
|
if (initial.length > maxVoices) {
|
||||||
|
initial = initial.slice(0, maxVoices);
|
||||||
|
}
|
||||||
|
setSelectedVoices(initial);
|
||||||
|
}, [isKokoro, maxVoices, configVoice, availableVoices]);
|
||||||
|
|
||||||
|
// If the saved voice is not in the available list, use the first available voice (non-Kokoro or Kokoro limited)
|
||||||
|
const currentVoice = useMemo(() => {
|
||||||
|
if (isKokoro && maxVoices > 1) {
|
||||||
|
const combined = buildKokoroVoiceString(selectedVoices);
|
||||||
|
return combined || (availableVoices[0] || '');
|
||||||
|
}
|
||||||
|
return (configVoice && availableVoices.includes(configVoice))
|
||||||
|
? configVoice
|
||||||
|
: availableVoices[0] || '';
|
||||||
|
}, [isKokoro, maxVoices, selectedVoices, availableVoices, configVoice]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Listbox value={currentVoice} onChange={setVoiceAndRestart}>
|
{(isKokoro && maxVoices > 1) ? (
|
||||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
<Listbox
|
||||||
<span>{currentVoice}</span>
|
multiple
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
value={selectedVoices}
|
||||||
</ListboxButton>
|
onChange={(vals: string[]) => {
|
||||||
<ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
if (!vals || vals.length === 0) return; // prevent empty selection
|
||||||
{availableVoices.map((voiceId) => (
|
|
||||||
<ListboxOption
|
let next = vals;
|
||||||
key={voiceId}
|
|
||||||
value={voiceId}
|
// Enforce provider max selection
|
||||||
className={({ active, selected }) =>
|
if (vals.length > maxVoices) {
|
||||||
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
|
const newlyAdded = vals.find(v => !selectedVoices.includes(v));
|
||||||
|
if (newlyAdded) {
|
||||||
|
const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? '';
|
||||||
|
const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean);
|
||||||
|
next = pair.slice(0, maxVoices);
|
||||||
|
} else {
|
||||||
|
next = vals.slice(-maxVoices);
|
||||||
}
|
}
|
||||||
>
|
}
|
||||||
<span className='text-xs sm:text-sm'>{voiceId}</span>
|
|
||||||
</ListboxOption>
|
setSelectedVoices(next);
|
||||||
))}
|
const combined = buildKokoroVoiceString(next);
|
||||||
</ListboxOptions>
|
if (combined) {
|
||||||
</Listbox>
|
setVoiceAndRestart(combined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||||
|
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
|
<span>
|
||||||
|
{selectedVoices.length > 1
|
||||||
|
? selectedVoices.join(' + ')
|
||||||
|
: selectedVoices[0] || currentVoice}
|
||||||
|
</span>
|
||||||
|
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||||
|
</ListboxButton>
|
||||||
|
<ListboxOptions anchor='top end' className="absolute z-50 w-40 sm:w-44 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
|
{availableVoices.map((voiceId) => (
|
||||||
|
<ListboxOption
|
||||||
|
key={voiceId}
|
||||||
|
value={voiceId}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className='text-xs sm:text-sm'>{voiceId}</span>
|
||||||
|
</ListboxOption>
|
||||||
|
))}
|
||||||
|
</ListboxOptions>
|
||||||
|
</Listbox>
|
||||||
|
) : (
|
||||||
|
<Listbox value={currentVoice} onChange={setVoiceAndRestart}>
|
||||||
|
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||||
|
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
|
<span>{currentVoice}</span>
|
||||||
|
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||||
|
</ListboxButton>
|
||||||
|
<ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
|
{availableVoices.map((voiceId) => (
|
||||||
|
<ListboxOption
|
||||||
|
key={voiceId}
|
||||||
|
value={voiceId}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className='text-xs sm:text-sm'>{voiceId}</span>
|
||||||
|
</ListboxOption>
|
||||||
|
))}
|
||||||
|
</ListboxOptions>
|
||||||
|
</Listbox>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,35 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
|
||||||
import { getItem, indexedDBService, setItem, removeItem } from '@/utils/indexedDB';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db, initDB, updateAppConfig } from '@/lib/dexie';
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
||||||
|
export type { ViewType } from '@/types/config';
|
||||||
/** Represents the possible view types for document display */
|
|
||||||
export type ViewType = 'single' | 'dual' | 'scroll';
|
|
||||||
|
|
||||||
/** Saved voice configurations per provider-model */
|
|
||||||
type SavedVoices = Record<string, string>;
|
|
||||||
|
|
||||||
/** Configuration values for the application */
|
/** Configuration values for the application */
|
||||||
type ConfigValues = {
|
|
||||||
apiKey: string;
|
|
||||||
baseUrl: string;
|
|
||||||
viewType: ViewType;
|
|
||||||
voiceSpeed: number;
|
|
||||||
audioPlayerSpeed: number;
|
|
||||||
voice: string;
|
|
||||||
skipBlank: boolean;
|
|
||||||
epubTheme: boolean;
|
|
||||||
headerMargin: number;
|
|
||||||
footerMargin: number;
|
|
||||||
leftMargin: number;
|
|
||||||
rightMargin: number;
|
|
||||||
ttsProvider: string;
|
|
||||||
ttsModel: string;
|
|
||||||
ttsInstructions: string;
|
|
||||||
savedVoices: SavedVoices;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Interface defining the configuration context shape and functionality */
|
/** Interface defining the configuration context shape and functionality */
|
||||||
interface ConfigContextType {
|
interface ConfigContextType {
|
||||||
|
|
@ -41,6 +18,7 @@ interface ConfigContextType {
|
||||||
voice: string;
|
voice: string;
|
||||||
skipBlank: boolean;
|
skipBlank: boolean;
|
||||||
epubTheme: boolean;
|
epubTheme: boolean;
|
||||||
|
smartSentenceSplitting: boolean;
|
||||||
headerMargin: number;
|
headerMargin: number;
|
||||||
footerMargin: number;
|
footerMargin: number;
|
||||||
leftMargin: number;
|
leftMargin: number;
|
||||||
|
|
@ -50,9 +28,10 @@ interface ConfigContextType {
|
||||||
ttsInstructions: string;
|
ttsInstructions: string;
|
||||||
savedVoices: SavedVoices;
|
savedVoices: SavedVoices;
|
||||||
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
||||||
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
|
updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isDBReady: boolean;
|
isDBReady: boolean;
|
||||||
|
pdfHighlightEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
@ -64,24 +43,6 @@ const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
||||||
*/
|
*/
|
||||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
// Config state
|
|
||||||
const [apiKey, setApiKey] = useState<string>('');
|
|
||||||
const [baseUrl, setBaseUrl] = useState<string>('');
|
|
||||||
const [viewType, setViewType] = useState<ViewType>('single');
|
|
||||||
const [voiceSpeed, setVoiceSpeed] = useState<number>(1);
|
|
||||||
const [audioPlayerSpeed, setAudioPlayerSpeed] = useState<number>(1);
|
|
||||||
const [voice, setVoice] = useState<string>('af_sarah');
|
|
||||||
const [skipBlank, setSkipBlank] = useState<boolean>(true);
|
|
||||||
const [epubTheme, setEpubTheme] = useState<boolean>(false);
|
|
||||||
const [headerMargin, setHeaderMargin] = useState<number>(0.07);
|
|
||||||
const [footerMargin, setFooterMargin] = useState<number>(0.07);
|
|
||||||
const [leftMargin, setLeftMargin] = useState<number>(0.07);
|
|
||||||
const [rightMargin, setRightMargin] = useState<number>(0.07);
|
|
||||||
const [ttsProvider, setTTSProvider] = useState<string>('custom-openai');
|
|
||||||
const [ttsModel, setTTSModel] = useState<string>('kokoro');
|
|
||||||
const [ttsInstructions, setTTSInstructions] = useState<string>('');
|
|
||||||
const [savedVoices, setSavedVoices] = useState<SavedVoices>({});
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDBReady, setIsDBReady] = useState(false);
|
const [isDBReady, setIsDBReady] = useState(false);
|
||||||
|
|
||||||
|
|
@ -92,155 +53,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const initializeDB = async () => {
|
const initializeDB = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await indexedDBService.init();
|
await initDB();
|
||||||
setIsDBReady(true);
|
setIsDBReady(true);
|
||||||
|
|
||||||
// Load config from IndexedDB
|
|
||||||
const cachedApiKey = await getItem('apiKey');
|
|
||||||
const cachedBaseUrl = await getItem('baseUrl');
|
|
||||||
const cachedViewType = await getItem('viewType');
|
|
||||||
const cachedVoiceSpeed = await getItem('voiceSpeed');
|
|
||||||
const cachedAudioPlayerSpeed = await getItem('audioPlayerSpeed');
|
|
||||||
const cachedSkipBlank = await getItem('skipBlank');
|
|
||||||
const cachedEpubTheme = await getItem('epubTheme');
|
|
||||||
const cachedHeaderMargin = await getItem('headerMargin');
|
|
||||||
const cachedFooterMargin = await getItem('footerMargin');
|
|
||||||
const cachedLeftMargin = await getItem('leftMargin');
|
|
||||||
const cachedRightMargin = await getItem('rightMargin');
|
|
||||||
const cachedTTSProvider = await getItem('ttsProvider');
|
|
||||||
const cachedTTSModel = await getItem('ttsModel');
|
|
||||||
const cachedTTSInstructions = await getItem('ttsInstructions');
|
|
||||||
const cachedSavedVoices = await getItem('savedVoices');
|
|
||||||
|
|
||||||
// Migration logic: infer provider and baseUrl for returning users
|
|
||||||
let inferredProvider = cachedTTSProvider || '';
|
|
||||||
let inferredBaseUrl = cachedBaseUrl || '';
|
|
||||||
|
|
||||||
// In production mode, force deepinfra provider if not already set
|
|
||||||
if (!isDev && !cachedTTSProvider) {
|
|
||||||
inferredProvider = 'deepinfra';
|
|
||||||
} else if (!inferredProvider) {
|
|
||||||
if (cachedBaseUrl) {
|
|
||||||
const baseUrlLower = cachedBaseUrl.toLowerCase();
|
|
||||||
if (baseUrlLower.includes('deepinfra.com')) {
|
|
||||||
inferredProvider = 'deepinfra';
|
|
||||||
} else if (baseUrlLower.includes('openai.com')) {
|
|
||||||
inferredProvider = 'openai';
|
|
||||||
} else if (
|
|
||||||
baseUrlLower.includes('localhost') ||
|
|
||||||
baseUrlLower.includes('127.0.0.1') ||
|
|
||||||
baseUrlLower.includes('internal')
|
|
||||||
) {
|
|
||||||
inferredProvider = 'custom-openai';
|
|
||||||
} else {
|
|
||||||
// Unknown host: fall back based on presence of API key
|
|
||||||
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No provider stored and no baseUrl stored
|
|
||||||
// If there is an API key and no base URL -> assume OpenAI
|
|
||||||
// If empty with no API key -> default to custom
|
|
||||||
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If baseUrl is missing, set a safe default based on the inferred provider
|
|
||||||
if (!inferredBaseUrl) {
|
|
||||||
if (inferredProvider === 'openai') {
|
|
||||||
inferredBaseUrl = 'https://api.openai.com/v1';
|
|
||||||
} else if (inferredProvider === 'deepinfra') {
|
|
||||||
inferredBaseUrl = 'https://api.deepinfra.com/v1/openai';
|
|
||||||
} else {
|
|
||||||
inferredBaseUrl = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set API key and base URL if they were explicitly saved by the user
|
|
||||||
if (cachedApiKey) {
|
|
||||||
console.log('Using cached API key');
|
|
||||||
setApiKey(cachedApiKey);
|
|
||||||
}
|
|
||||||
if (cachedBaseUrl) {
|
|
||||||
console.log('Using cached base URL');
|
|
||||||
setBaseUrl(cachedBaseUrl);
|
|
||||||
} else if (inferredBaseUrl) {
|
|
||||||
// Migration: no stored baseUrl, pick a safe default from provider inference
|
|
||||||
console.log('Setting default base URL from inferred provider', inferredBaseUrl);
|
|
||||||
setBaseUrl(inferredBaseUrl);
|
|
||||||
await setItem('baseUrl', inferredBaseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse savedVoices
|
|
||||||
let parsedSavedVoices: SavedVoices = {};
|
|
||||||
if (cachedSavedVoices) {
|
|
||||||
try {
|
|
||||||
parsedSavedVoices = JSON.parse(cachedSavedVoices);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing savedVoices:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSavedVoices(parsedSavedVoices);
|
|
||||||
|
|
||||||
// Set the other values with defaults
|
|
||||||
setViewType((cachedViewType || 'single') as ViewType);
|
|
||||||
setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1'));
|
|
||||||
setAudioPlayerSpeed(parseFloat(cachedAudioPlayerSpeed || '1'));
|
|
||||||
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
|
|
||||||
setEpubTheme(cachedEpubTheme === 'true');
|
|
||||||
setHeaderMargin(parseFloat(cachedHeaderMargin || '0.07'));
|
|
||||||
setFooterMargin(parseFloat(cachedFooterMargin || '0.07'));
|
|
||||||
setLeftMargin(parseFloat(cachedLeftMargin || '0.07'));
|
|
||||||
setRightMargin(parseFloat(cachedRightMargin || '0.07'));
|
|
||||||
setTTSProvider(inferredProvider || 'custom-openai');
|
|
||||||
const finalModel = cachedTTSModel || (inferredProvider === 'openai' ? 'tts-1' : inferredProvider === 'deepinfra' ? 'hexgrad/Kokoro-82M' : 'kokoro');
|
|
||||||
setTTSModel(finalModel);
|
|
||||||
setTTSInstructions(cachedTTSInstructions || '');
|
|
||||||
|
|
||||||
// Restore voice for current provider-model if available in savedVoices
|
|
||||||
const voiceKey = getVoiceKey(inferredProvider || 'custom-openai', finalModel);
|
|
||||||
const restoredVoice = parsedSavedVoices[voiceKey] || '';
|
|
||||||
setVoice(restoredVoice);
|
|
||||||
|
|
||||||
// Only save non-sensitive settings by default
|
|
||||||
if (!cachedViewType) {
|
|
||||||
await setItem('viewType', 'single');
|
|
||||||
}
|
|
||||||
if (cachedSkipBlank === null) {
|
|
||||||
await setItem('skipBlank', 'true');
|
|
||||||
}
|
|
||||||
if (cachedEpubTheme === null) {
|
|
||||||
await setItem('epubTheme', 'false');
|
|
||||||
}
|
|
||||||
if (cachedHeaderMargin === null) await setItem('headerMargin', '0.07');
|
|
||||||
if (cachedFooterMargin === null) await setItem('footerMargin', '0.07');
|
|
||||||
if (cachedLeftMargin === null) await setItem('leftMargin', '0.0');
|
|
||||||
if (cachedRightMargin === null) await setItem('rightMargin', '0.0');
|
|
||||||
if (cachedTTSProvider === null && inferredProvider) {
|
|
||||||
await setItem('ttsProvider', inferredProvider);
|
|
||||||
} else if (cachedTTSProvider === null) {
|
|
||||||
await setItem('ttsProvider', 'custom-openai');
|
|
||||||
}
|
|
||||||
if (cachedTTSModel === null) {
|
|
||||||
const defaultModel = inferredProvider === 'openai' ? 'tts-1' : inferredProvider === 'deepinfra' ? 'hexgrad/Kokoro-82M' : 'kokoro';
|
|
||||||
await setItem('ttsModel', defaultModel);
|
|
||||||
}
|
|
||||||
if (cachedTTSInstructions === null) {
|
|
||||||
await setItem('ttsInstructions', '');
|
|
||||||
}
|
|
||||||
if (!cachedVoiceSpeed) {
|
|
||||||
await setItem('voiceSpeed', '1');
|
|
||||||
}
|
|
||||||
if (!cachedAudioPlayerSpeed) {
|
|
||||||
await setItem('audioPlayerSpeed', '1');
|
|
||||||
}
|
|
||||||
if (!cachedSavedVoices) {
|
|
||||||
await setItem('savedVoices', JSON.stringify({}));
|
|
||||||
}
|
|
||||||
// Always ensure voice is not stored standalone - only in savedVoices
|
|
||||||
await removeItem('voice');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing:', error);
|
console.error('Error initializing Dexie:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +65,45 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
initializeDB();
|
initializeDB();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const appConfig = useLiveQuery(
|
||||||
|
async () => {
|
||||||
|
if (!isDBReady) return null;
|
||||||
|
const row = await db['app-config'].get('singleton');
|
||||||
|
return row ?? null;
|
||||||
|
},
|
||||||
|
[isDBReady],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config: AppConfigValues | null = useMemo(() => {
|
||||||
|
if (!appConfig) return null;
|
||||||
|
const { id, ...rest } = appConfig;
|
||||||
|
void id;
|
||||||
|
return rest;
|
||||||
|
}, [appConfig]);
|
||||||
|
|
||||||
|
// Destructure for convenience and to match context shape
|
||||||
|
const {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
viewType,
|
||||||
|
voiceSpeed,
|
||||||
|
audioPlayerSpeed,
|
||||||
|
voice,
|
||||||
|
skipBlank,
|
||||||
|
epubTheme,
|
||||||
|
headerMargin,
|
||||||
|
footerMargin,
|
||||||
|
leftMargin,
|
||||||
|
rightMargin,
|
||||||
|
ttsProvider,
|
||||||
|
ttsModel,
|
||||||
|
ttsInstructions,
|
||||||
|
savedVoices,
|
||||||
|
smartSentenceSplitting,
|
||||||
|
pdfHighlightEnabled,
|
||||||
|
} = config || APP_CONFIG_DEFAULTS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates multiple configuration values simultaneously
|
* Updates multiple configuration values simultaneously
|
||||||
* Only saves API credentials if they are explicitly set
|
* Only saves API credentials if they are explicitly set
|
||||||
|
|
@ -256,27 +111,14 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
|
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
if (newConfig.apiKey !== undefined && newConfig.apiKey !== '') {
|
const updates: Partial<AppConfigRow> = {};
|
||||||
// Only save API key to IndexedDB if it's different from env default
|
if (newConfig.apiKey !== undefined) {
|
||||||
await setItem('apiKey', newConfig.apiKey!);
|
updates.apiKey = newConfig.apiKey;
|
||||||
setApiKey(newConfig.apiKey!);
|
|
||||||
}
|
}
|
||||||
if (newConfig.baseUrl !== undefined && newConfig.baseUrl !== '') {
|
if (newConfig.baseUrl !== undefined) {
|
||||||
// Only save base URL to IndexedDB if it's different from env default
|
updates.baseUrl = newConfig.baseUrl;
|
||||||
await setItem('baseUrl', newConfig.baseUrl!);
|
|
||||||
setBaseUrl(newConfig.baseUrl!);
|
|
||||||
}
|
}
|
||||||
|
await updateAppConfig(updates);
|
||||||
// Delete completely if '' is passed
|
|
||||||
if (newConfig.apiKey === '') {
|
|
||||||
await removeItem('apiKey');
|
|
||||||
setApiKey('');
|
|
||||||
}
|
|
||||||
if (newConfig.baseUrl === '') {
|
|
||||||
await removeItem('baseUrl');
|
|
||||||
setBaseUrl('');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating config:', error);
|
console.error('Error updating config:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -288,9 +130,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
/**
|
/**
|
||||||
* Updates a single configuration value by key
|
* Updates a single configuration value by key
|
||||||
* @param {K} key - The configuration key to update
|
* @param {K} key - The configuration key to update
|
||||||
* @param {ConfigValues[K]} value - The new value for the configuration
|
* @param {AppConfigValues[K]} value - The new value for the configuration
|
||||||
*/
|
*/
|
||||||
const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => {
|
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
|
@ -298,80 +140,35 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
if (key === 'voice') {
|
if (key === 'voice') {
|
||||||
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
|
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
|
||||||
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
|
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
|
||||||
setSavedVoices(updatedSavedVoices);
|
await updateAppConfig({
|
||||||
await setItem('savedVoices', JSON.stringify(updatedSavedVoices));
|
savedVoices: updatedSavedVoices,
|
||||||
setVoice(value as string);
|
voice: value as string,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Special handling for provider/model changes - restore saved voice if available
|
// Special handling for provider/model changes - restore saved voice if available
|
||||||
else if (key === 'ttsProvider' || key === 'ttsModel') {
|
else if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||||
const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider;
|
const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider;
|
||||||
const newModel = key === 'ttsModel' ? (value as string) : ttsModel;
|
const newModel = key === 'ttsModel' ? (value as string) : ttsModel;
|
||||||
const voiceKey = getVoiceKey(newProvider, newModel);
|
const voiceKey = getVoiceKey(newProvider, newModel);
|
||||||
|
const restoredVoice = savedVoices[voiceKey] || '';
|
||||||
// Update provider or model
|
await updateAppConfig({
|
||||||
await setItem(key, value.toString());
|
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||||
if (key === 'ttsProvider') {
|
voice: restoredVoice,
|
||||||
setTTSProvider(value as string);
|
} as Partial<AppConfigRow>);
|
||||||
} else {
|
|
||||||
setTTSModel(value as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore voice for this provider-model combination if it exists
|
|
||||||
const restoredVoice = savedVoices[voiceKey];
|
|
||||||
if (restoredVoice) {
|
|
||||||
setVoice(restoredVoice);
|
|
||||||
} else {
|
|
||||||
// Clear voice so TTSContext will use first available
|
|
||||||
setVoice('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (key === 'savedVoices') {
|
else if (key === 'savedVoices') {
|
||||||
setSavedVoices(value as SavedVoices);
|
const newSavedVoices = value as SavedVoices;
|
||||||
await setItem('savedVoices', JSON.stringify(value));
|
await updateAppConfig({
|
||||||
|
savedVoices: newSavedVoices,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await setItem(key, value.toString());
|
await updateAppConfig({
|
||||||
switch (key) {
|
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||||
case 'apiKey':
|
} as Partial<AppConfigRow>);
|
||||||
setApiKey(value as string);
|
|
||||||
break;
|
|
||||||
case 'baseUrl':
|
|
||||||
setBaseUrl(value as string);
|
|
||||||
break;
|
|
||||||
case 'viewType':
|
|
||||||
setViewType(value as ViewType);
|
|
||||||
break;
|
|
||||||
case 'voiceSpeed':
|
|
||||||
setVoiceSpeed(value as number);
|
|
||||||
break;
|
|
||||||
case 'audioPlayerSpeed':
|
|
||||||
setAudioPlayerSpeed(value as number);
|
|
||||||
break;
|
|
||||||
case 'skipBlank':
|
|
||||||
setSkipBlank(value as boolean);
|
|
||||||
break;
|
|
||||||
case 'epubTheme':
|
|
||||||
setEpubTheme(value as boolean);
|
|
||||||
break;
|
|
||||||
case 'headerMargin':
|
|
||||||
setHeaderMargin(value as number);
|
|
||||||
break;
|
|
||||||
case 'footerMargin':
|
|
||||||
setFooterMargin(value as number);
|
|
||||||
break;
|
|
||||||
case 'leftMargin':
|
|
||||||
setLeftMargin(value as number);
|
|
||||||
break;
|
|
||||||
case 'rightMargin':
|
|
||||||
setRightMargin(value as number);
|
|
||||||
break;
|
|
||||||
case 'ttsInstructions':
|
|
||||||
setTTSInstructions(value as string);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error updating config key ${key}:`, error);
|
console.error(`Error updating config key ${String(key)}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -388,6 +185,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
voice,
|
voice,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
epubTheme,
|
epubTheme,
|
||||||
|
smartSentenceSplitting,
|
||||||
headerMargin,
|
headerMargin,
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
|
|
@ -399,7 +197,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
updateConfig,
|
updateConfig,
|
||||||
updateConfigKey,
|
updateConfigKey,
|
||||||
isLoading,
|
isLoading,
|
||||||
isDBReady
|
isDBReady,
|
||||||
|
pdfHighlightEnabled
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
|
|
@ -417,4 +216,4 @@ export function useConfig() {
|
||||||
throw new Error('useConfig must be used within a ConfigProvider');
|
throw new Error('useConfig must be used within a ConfigProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,6 @@ interface DocumentContextType {
|
||||||
removeHTMLDocument: (id: string) => Promise<void>;
|
removeHTMLDocument: (id: string) => Promise<void>;
|
||||||
isHTMLLoading: boolean;
|
isHTMLLoading: boolean;
|
||||||
|
|
||||||
refreshPDFs: () => Promise<void>;
|
|
||||||
refreshEPUBs: () => Promise<void>;
|
|
||||||
refreshHTML: () => Promise<void>;
|
|
||||||
|
|
||||||
clearPDFs: () => Promise<void>;
|
clearPDFs: () => Promise<void>;
|
||||||
clearEPUBs: () => Promise<void>;
|
clearEPUBs: () => Promise<void>;
|
||||||
clearHTML: () => Promise<void>;
|
clearHTML: () => Promise<void>;
|
||||||
|
|
@ -42,7 +38,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
addDocument: addPDFDocument,
|
addDocument: addPDFDocument,
|
||||||
removeDocument: removePDFDocument,
|
removeDocument: removePDFDocument,
|
||||||
isLoading: isPDFLoading,
|
isLoading: isPDFLoading,
|
||||||
refresh: refreshPDFs,
|
|
||||||
clearDocuments: clearPDFs
|
clearDocuments: clearPDFs
|
||||||
} = usePDFDocuments();
|
} = usePDFDocuments();
|
||||||
|
|
||||||
|
|
@ -51,7 +46,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
addDocument: addEPUBDocument,
|
addDocument: addEPUBDocument,
|
||||||
removeDocument: removeEPUBDocument,
|
removeDocument: removeEPUBDocument,
|
||||||
isLoading: isEPUBLoading,
|
isLoading: isEPUBLoading,
|
||||||
refresh: refreshEPUBs,
|
|
||||||
clearDocuments: clearEPUBs
|
clearDocuments: clearEPUBs
|
||||||
} = useEPUBDocuments();
|
} = useEPUBDocuments();
|
||||||
|
|
||||||
|
|
@ -60,7 +54,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
addDocument: addHTMLDocument,
|
addDocument: addHTMLDocument,
|
||||||
removeDocument: removeHTMLDocument,
|
removeDocument: removeHTMLDocument,
|
||||||
isLoading: isHTMLLoading,
|
isLoading: isHTMLLoading,
|
||||||
refresh: refreshHTML,
|
|
||||||
clearDocuments: clearHTML
|
clearDocuments: clearHTML
|
||||||
} = useHTMLDocuments();
|
} = useHTMLDocuments();
|
||||||
|
|
||||||
|
|
@ -78,9 +71,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
addHTMLDocument,
|
addHTMLDocument,
|
||||||
removeHTMLDocument,
|
removeHTMLDocument,
|
||||||
isHTMLLoading,
|
isHTMLLoading,
|
||||||
refreshPDFs,
|
|
||||||
refreshEPUBs,
|
|
||||||
refreshHTML,
|
|
||||||
clearPDFs,
|
clearPDFs,
|
||||||
clearEPUBs,
|
clearEPUBs,
|
||||||
clearHTML
|
clearHTML
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,20 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
RefObject,
|
RefObject
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { indexedDBService } from '@/utils/indexedDB';
|
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
|
||||||
import { Book, Rendition } from 'epubjs';
|
|
||||||
import { createRangeCfi } from '@/utils/epub';
|
|
||||||
import type { NavItem } from 'epubjs';
|
import type { NavItem } from 'epubjs';
|
||||||
import { setLastDocumentLocation } from '@/utils/indexedDB';
|
import type { SpineItem } from 'epubjs/types/section';
|
||||||
import { SpineItem } from 'epubjs/types/section';
|
import type { Book, Rendition } from 'epubjs';
|
||||||
|
|
||||||
|
import { getEpubDocument, setLastDocumentLocation } from '@/lib/dexie';
|
||||||
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
import { createRangeCfi } from '@/lib/epub';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
import { combineAudioChunks } from '@/utils/audio';
|
|
||||||
import { withRetry } from '@/utils/audio';
|
import { withRetry } from '@/utils/audio';
|
||||||
|
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
|
||||||
|
|
||||||
interface EPUBContextType {
|
interface EPUBContextType {
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
|
|
@ -31,7 +32,8 @@ interface EPUBContextType {
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
||||||
|
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
||||||
bookRef: RefObject<Book | null>;
|
bookRef: RefObject<Book | null>;
|
||||||
renditionRef: RefObject<Rendition | undefined>;
|
renditionRef: RefObject<Rendition | undefined>;
|
||||||
tocRef: RefObject<NavItem[]>;
|
tocRef: RefObject<NavItem[]>;
|
||||||
|
|
@ -43,6 +45,81 @@ interface EPUBContextType {
|
||||||
|
|
||||||
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
|
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const EPUB_CONTINUATION_CHARS = 600;
|
||||||
|
|
||||||
|
const stepToNextNode = (node: Node | null, root: Node): Node | null => {
|
||||||
|
if (!node) return null;
|
||||||
|
if (node.firstChild) {
|
||||||
|
return node.firstChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: Node | null = node;
|
||||||
|
while (current) {
|
||||||
|
if (current === root) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (current.nextSibling) {
|
||||||
|
return current.nextSibling;
|
||||||
|
}
|
||||||
|
current = current.parentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextTextNode = (node: Node | null, root: Node): Text | null => {
|
||||||
|
let next = stepToNextNode(node, root);
|
||||||
|
while (next) {
|
||||||
|
if (next.nodeType === Node.TEXT_NODE) {
|
||||||
|
return next as Text;
|
||||||
|
}
|
||||||
|
next = stepToNextNode(next, root);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectContinuationFromRange = (range: Range | null | undefined, limit = EPUB_CONTINUATION_CHARS): string => {
|
||||||
|
if (typeof window === 'undefined' || !range) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = range.commonAncestorContainer;
|
||||||
|
if (!root) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
let remaining = limit;
|
||||||
|
|
||||||
|
const appendFromTextNode = (textNode: Text, offset: number) => {
|
||||||
|
if (remaining <= 0) return;
|
||||||
|
const textContent = textNode.textContent || '';
|
||||||
|
if (offset >= textContent.length) return;
|
||||||
|
const slice = textContent.slice(offset, offset + remaining);
|
||||||
|
if (slice) {
|
||||||
|
parts.push(slice);
|
||||||
|
remaining -= slice.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (range.endContainer.nodeType === Node.TEXT_NODE) {
|
||||||
|
appendFromTextNode(range.endContainer as Text, range.endOffset);
|
||||||
|
let nextNode = getNextTextNode(range.endContainer, root);
|
||||||
|
while (nextNode && remaining > 0) {
|
||||||
|
appendFromTextNode(nextNode, 0);
|
||||||
|
nextNode = getNextTextNode(nextNode, root);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let nextNode = getNextTextNode(range.endContainer, root);
|
||||||
|
while (nextNode && remaining > 0) {
|
||||||
|
appendFromTextNode(nextNode, 0);
|
||||||
|
nextNode = getNextTextNode(nextNode, root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider component for EPUB functionality
|
* Provider component for EPUB functionality
|
||||||
* Manages the state and operations for EPUB document handling
|
* Manages the state and operations for EPUB document handling
|
||||||
|
|
@ -59,12 +136,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
voiceSpeed,
|
voiceSpeed,
|
||||||
voice,
|
voice,
|
||||||
ttsProvider,
|
ttsProvider,
|
||||||
|
ttsModel,
|
||||||
|
ttsInstructions,
|
||||||
|
smartSentenceSplitting,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
// Current document state
|
// Current document state
|
||||||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
const [isAudioCombining] = useState(false);
|
||||||
|
|
||||||
// Add new refs
|
// Add new refs
|
||||||
const bookRef = useRef<Book | null>(null);
|
const bookRef = useRef<Book | null>(null);
|
||||||
|
|
@ -98,7 +178,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
*/
|
*/
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const doc = await indexedDBService.getEPUBDocument(id);
|
const doc = await getEpubDocument(id);
|
||||||
if (doc) {
|
if (doc) {
|
||||||
console.log('Retrieved document size:', doc.size);
|
console.log('Retrieved document size:', doc.size);
|
||||||
console.log('Retrieved ArrayBuffer size:', doc.data.byteLength);
|
console.log('Retrieved ArrayBuffer size:', doc.data.byteLength);
|
||||||
|
|
@ -139,8 +219,23 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const textContent = range.toString().trim();
|
const textContent = range.toString().trim();
|
||||||
|
const continuationPreview = collectContinuationFromRange(range);
|
||||||
|
|
||||||
setTTSText(textContent, shouldPause);
|
if (smartSentenceSplitting) {
|
||||||
|
setTTSText(textContent, {
|
||||||
|
shouldPause,
|
||||||
|
location: start.cfi,
|
||||||
|
nextLocation: end.cfi,
|
||||||
|
nextText: continuationPreview
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// When smart splitting is disabled, behave like the original implementation:
|
||||||
|
// send only the current page/location text without any continuation preview.
|
||||||
|
setTTSText(textContent, {
|
||||||
|
shouldPause,
|
||||||
|
location: start.cfi,
|
||||||
|
});
|
||||||
|
}
|
||||||
setCurrDocText(textContent);
|
setCurrDocText(textContent);
|
||||||
|
|
||||||
return textContent;
|
return textContent;
|
||||||
|
|
@ -148,7 +243,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
console.error('Error extracting EPUB text:', error);
|
console.error('Error extracting EPUB text:', error);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}, [setTTSText]);
|
}, [setTTSText, smartSentenceSplitting]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts text content from the entire EPUB book
|
* Extracts text content from the entire EPUB book
|
||||||
|
|
@ -197,21 +292,43 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const createFullAudioBook = useCallback(async (
|
const createFullAudioBook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||||
|
providedBookId?: string,
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
format: 'mp3' | 'm4b' = 'mp3'
|
||||||
): Promise<ArrayBuffer> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const sections = await extractBookText();
|
const sections = await extractBookText();
|
||||||
if (!sections.length) throw new Error('No text content found in book');
|
if (!sections.length) throw new Error('No text content found in book');
|
||||||
|
|
||||||
// Calculate total length for accurate progress tracking
|
// Calculate total length for accurate progress tracking
|
||||||
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
|
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
|
||||||
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
|
|
||||||
let processedLength = 0;
|
let processedLength = 0;
|
||||||
let currentTime = 0;
|
let bookId: string = providedBookId || '';
|
||||||
|
|
||||||
// Get TOC for chapter titles
|
// Get TOC for chapter titles
|
||||||
const chapters = tocRef.current || [];
|
const chapters = tocRef.current || [];
|
||||||
console.log('Chapters:', chapters);
|
|
||||||
|
// If we have a bookId, check for existing chapters to determine which indices already exist
|
||||||
|
const existingIndices = new Set<number>();
|
||||||
|
if (bookId) {
|
||||||
|
try {
|
||||||
|
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
if (existingResponse.ok) {
|
||||||
|
const existingData = await existingResponse.json();
|
||||||
|
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||||
|
for (const ch of existingData.chapters) {
|
||||||
|
existingIndices.add(ch.index);
|
||||||
|
}
|
||||||
|
// Log smallest missing index for visibility
|
||||||
|
let nextMissing = 0;
|
||||||
|
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||||
|
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking existing chapters:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create a map of section hrefs to their chapter titles
|
// Create a map of section hrefs to their chapter titles
|
||||||
const sectionTitleMap = new Map<string, string>();
|
const sectionTitleMap = new Map<string, string>();
|
||||||
|
|
@ -233,36 +350,64 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Section to chapter title mapping:', sectionTitleMap);
|
|
||||||
|
|
||||||
// Process each section
|
// Process each section
|
||||||
for (let i = 0; i < sections.length; i++) {
|
for (let i = 0; i < sections.length; i++) {
|
||||||
|
// Check for abort at the start of iteration
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
console.log('Generation cancelled by user');
|
||||||
return partialBuffer;
|
if (bookId) {
|
||||||
|
return bookId; // Return bookId with partial progress
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const section = sections[i];
|
const section = sections[i];
|
||||||
const trimmedText = section.text.trim();
|
const trimmedText = section.text.trim();
|
||||||
if (!trimmedText) continue;
|
if (!trimmedText) continue;
|
||||||
|
|
||||||
|
// Skip chapters that already exist on disk (supports non-contiguous indices)
|
||||||
|
if (existingIndices.has(i)) {
|
||||||
|
processedLength += trimmedText.length;
|
||||||
|
onProgress((processedLength / totalLength) * 100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-openai-key': apiKey,
|
||||||
|
'x-openai-base-url': baseUrl,
|
||||||
|
'x-tts-provider': ttsProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqBody: TTSRequestPayload = {
|
||||||
|
text: trimmedText,
|
||||||
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
|
speed: voiceSpeed,
|
||||||
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryOptions: TTSRetryOptions = {
|
||||||
|
maxRetries: 2,
|
||||||
|
initialDelay: 5000,
|
||||||
|
maxDelay: 10000,
|
||||||
|
backoffFactor: 2
|
||||||
|
};
|
||||||
|
|
||||||
const audioBuffer = await withRetry(
|
const audioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
// Check for abort before starting TTS request
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: reqHeaders,
|
||||||
'x-openai-key': apiKey,
|
body: JSON.stringify(reqBody),
|
||||||
'x-openai-base-url': baseUrl,
|
|
||||||
'x-tts-provider': ttsProvider,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: trimmedText,
|
|
||||||
voice: voice,
|
|
||||||
speed: voiceSpeed,
|
|
||||||
format: format === 'm4b' ? 'aac' : 'mp3',
|
|
||||||
}),
|
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -276,12 +421,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
},
|
},
|
||||||
{
|
retryOptions
|
||||||
maxRetries: 2,
|
|
||||||
initialDelay: 5000,
|
|
||||||
maxDelay: 10000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the chapter title from our pre-computed map
|
// Get the chapter title from our pre-computed map
|
||||||
|
|
@ -289,47 +429,237 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
// If no chapter title found, use index-based naming
|
// If no chapter title found, use index-based naming
|
||||||
if (!chapterTitle) {
|
if (!chapterTitle) {
|
||||||
chapterTitle = `Unknown Section - ${i + 1}`;
|
chapterTitle = `Chapter ${i + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Processed audiobook chapter title:', chapterTitle);
|
// Check for abort before sending to server
|
||||||
audioChunks.push({
|
if (signal?.aborted) {
|
||||||
buffer: audioBuffer,
|
console.log('Generation cancelled before saving chapter');
|
||||||
title: chapterTitle,
|
if (bookId) {
|
||||||
startTime: currentTime
|
return bookId;
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server for conversion and storage
|
||||||
|
const convertResponse = await fetch('/api/audio/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapterTitle,
|
||||||
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
chapterIndex: i
|
||||||
|
}),
|
||||||
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add silence between sections
|
if (convertResponse.status === 499) {
|
||||||
const silenceBuffer = new ArrayBuffer(48000);
|
throw new Error('cancelled');
|
||||||
audioChunks.push({
|
}
|
||||||
buffer: silenceBuffer,
|
|
||||||
startTime: currentTime + (audioBuffer.byteLength / 48000)
|
if (!convertResponse.ok) {
|
||||||
});
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
|
||||||
|
|
||||||
|
if (!bookId) {
|
||||||
|
bookId = returnedBookId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify about completed chapter
|
||||||
|
if (onChapterComplete) {
|
||||||
|
onChapterComplete({
|
||||||
|
index: chapterIndex,
|
||||||
|
title: chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
currentTime += (audioBuffer.byteLength + 48000) / 48000;
|
|
||||||
processedLength += trimmedText.length;
|
processedLength += trimmedText.length;
|
||||||
onProgress((processedLength / totalLength) * 100);
|
onProgress((processedLength / totalLength) * 100);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
console.log('TTS request aborted');
|
console.log('TTS request aborted, returning partial progress');
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
if (bookId) {
|
||||||
return partialBuffer;
|
return bookId; // Return with partial progress
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
console.error('Error processing section:', error);
|
console.error('Error processing section:', error);
|
||||||
|
|
||||||
|
// Notify about error
|
||||||
|
if (onChapterComplete) {
|
||||||
|
onChapterComplete({
|
||||||
|
index: i,
|
||||||
|
title: sectionTitleMap.get(section.href) || `Chapter ${i + 1}`,
|
||||||
|
status: 'error',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioChunks.length === 0) {
|
if (!bookId) {
|
||||||
throw new Error('No audio was generated from the book content');
|
throw new Error('No audio was generated from the book content');
|
||||||
}
|
}
|
||||||
|
|
||||||
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
return bookId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
|
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerates a specific chapter of the audiobook
|
||||||
|
*/
|
||||||
|
const regenerateChapter = useCallback(async (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
||||||
|
try {
|
||||||
|
const sections = await extractBookText();
|
||||||
|
if (chapterIndex >= sections.length) {
|
||||||
|
throw new Error('Invalid chapter index');
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = sections[chapterIndex];
|
||||||
|
const trimmedText = section.text.trim();
|
||||||
|
|
||||||
|
if (!trimmedText) {
|
||||||
|
throw new Error('No text content found in chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get TOC for chapter title
|
||||||
|
const chapters = tocRef.current || [];
|
||||||
|
const sectionTitleMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const chapter of chapters) {
|
||||||
|
if (!chapter.href) continue;
|
||||||
|
const chapterBaseHref = chapter.href.split('#')[0];
|
||||||
|
const chapterTitle = chapter.label.trim();
|
||||||
|
|
||||||
|
for (const sect of sections) {
|
||||||
|
const sectionHref = sect.href;
|
||||||
|
const sectionBaseHref = sectionHref.split('#')[0];
|
||||||
|
|
||||||
|
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
|
||||||
|
sectionTitleMap.set(sectionHref, chapterTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterTitle = sectionTitleMap.get(section.href) || `Chapter ${chapterIndex + 1}`;
|
||||||
|
|
||||||
|
// Generate audio with retry logic
|
||||||
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-openai-key': apiKey,
|
||||||
|
'x-openai-base-url': baseUrl,
|
||||||
|
'x-tts-provider': ttsProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqBody: TTSRequestPayload = {
|
||||||
|
text: trimmedText,
|
||||||
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
|
speed: voiceSpeed,
|
||||||
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryOptions: TTSRetryOptions = {
|
||||||
|
maxRetries: 2,
|
||||||
|
initialDelay: 5000,
|
||||||
|
maxDelay: 10000,
|
||||||
|
backoffFactor: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const audioBuffer = await withRetry(
|
||||||
|
async () => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: reqHeaders,
|
||||||
|
body: JSON.stringify(reqBody),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ttsResponse.ok) {
|
||||||
|
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await ttsResponse.arrayBuffer();
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
throw new Error('Received empty audio buffer from TTS');
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
},
|
||||||
|
retryOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error('Chapter regeneration cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server for conversion and storage
|
||||||
|
const convertResponse = await fetch('/api/audio/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapterTitle,
|
||||||
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
chapterIndex
|
||||||
|
}),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (convertResponse.status === 499) {
|
||||||
|
throw new Error('cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!convertResponse.ok) {
|
||||||
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: returnedIndex,
|
||||||
|
title: chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
|
throw new Error('Chapter regeneration cancelled');
|
||||||
|
}
|
||||||
|
console.error('Error regenerating chapter:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
const setRendition = useCallback((rendition: Rendition) => {
|
const setRendition = useCallback((rendition: Rendition) => {
|
||||||
bookRef.current = rendition.book;
|
bookRef.current = rendition.book;
|
||||||
|
|
@ -387,6 +717,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
bookRef,
|
bookRef,
|
||||||
renditionRef,
|
renditionRef,
|
||||||
tocRef,
|
tocRef,
|
||||||
|
|
@ -405,6 +736,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
handleLocationChanged,
|
handleLocationChanged,
|
||||||
setRendition,
|
setRendition,
|
||||||
isAudioCombining,
|
isAudioCombining,
|
||||||
|
|
@ -429,4 +761,4 @@ export function useEPUB() {
|
||||||
throw new Error('useEPUB must be used within an EPUBProvider');
|
throw new Error('useEPUB must be used within an EPUBProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,8 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { indexedDBService } from '@/utils/indexedDB';
|
import { getHtmlDocument } from '@/lib/dexie';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
|
||||||
import { combineAudioChunks, withRetry } from '@/utils/audio';
|
|
||||||
|
|
||||||
interface HTMLContextType {
|
interface HTMLContextType {
|
||||||
currDocData: string | undefined;
|
currDocData: string | undefined;
|
||||||
|
|
@ -19,8 +17,6 @@ interface HTMLContextType {
|
||||||
currDocText: string | undefined;
|
currDocText: string | undefined;
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
|
|
||||||
isAudioCombining: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
||||||
|
|
@ -33,13 +29,12 @@ const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
||||||
*/
|
*/
|
||||||
export function HTMLProvider({ children }: { children: ReactNode }) {
|
export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
const { setText: setTTSText, stop } = useTTS();
|
const { setText: setTTSText, stop } = useTTS();
|
||||||
const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider } = useConfig();
|
|
||||||
|
|
||||||
// Current document state
|
// Current document state
|
||||||
const [currDocData, setCurrDocData] = useState<string>();
|
const [currDocData, setCurrDocData] = useState<string>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all current document state and stops any active TTS
|
* Clears all current document state and stops any active TTS
|
||||||
|
|
@ -58,7 +53,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
*/
|
*/
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const doc = await indexedDBService.getHTMLDocument(id);
|
const doc = await getHtmlDocument(id);
|
||||||
if (doc) {
|
if (doc) {
|
||||||
setCurrDocName(doc.name);
|
setCurrDocName(doc.name);
|
||||||
setCurrDocData(doc.data);
|
setCurrDocData(doc.data);
|
||||||
|
|
@ -73,81 +68,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [clearCurrDoc, setTTSText]);
|
}, [clearCurrDoc, setTTSText]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a complete audiobook from the document text
|
|
||||||
*/
|
|
||||||
const createFullAudioBook = useCallback(async (
|
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
|
||||||
): Promise<ArrayBuffer> => {
|
|
||||||
try {
|
|
||||||
if (!currDocText) {
|
|
||||||
throw new Error('No text content found in document');
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
|
|
||||||
const currentTime = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const audioBuffer = await withRetry(
|
|
||||||
async () => {
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-openai-key': apiKey,
|
|
||||||
'x-openai-base-url': baseUrl,
|
|
||||||
'x-tts-provider': ttsProvider,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: currDocText,
|
|
||||||
voice: voice,
|
|
||||||
speed: voiceSpeed,
|
|
||||||
format: format === 'm4b' ? 'aac' : 'mp3'
|
|
||||||
}),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ttsResponse.ok) {
|
|
||||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await ttsResponse.arrayBuffer();
|
|
||||||
if (buffer.byteLength === 0) {
|
|
||||||
throw new Error('Received empty audio buffer from TTS');
|
|
||||||
}
|
|
||||||
return buffer;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
maxRetries: 3,
|
|
||||||
initialDelay: 1000,
|
|
||||||
maxDelay: 5000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
audioChunks.push({
|
|
||||||
buffer: audioBuffer,
|
|
||||||
title: currDocName,
|
|
||||||
startTime: currentTime
|
|
||||||
});
|
|
||||||
|
|
||||||
onProgress(100); // Single chunk, so we're done when it completes
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
|
||||||
console.log('TTS request aborted');
|
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
|
||||||
return partialBuffer;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating audiobook:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [currDocText, currDocName, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
|
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
currDocData,
|
currDocData,
|
||||||
|
|
@ -155,16 +76,12 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
currDocText,
|
currDocText,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
createFullAudioBook,
|
|
||||||
isAudioCombining,
|
|
||||||
}), [
|
}), [
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocName,
|
currDocName,
|
||||||
currDocText,
|
currDocText,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
createFullAudioBook,
|
|
||||||
isAudioCombining,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -22,20 +22,24 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
RefObject,
|
RefObject,
|
||||||
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { indexedDBService } from '@/utils/indexedDB';
|
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||||
|
|
||||||
|
import { getPdfDocument } from '@/lib/dexie';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
import { processTextToSentences } from '@/lib/nlp';
|
||||||
|
import { withRetry } from '@/utils/audio';
|
||||||
import {
|
import {
|
||||||
extractTextFromPDF,
|
extractTextFromPDF,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
handleTextClick,
|
handleTextClick,
|
||||||
} from '@/utils/pdf';
|
} from '@/lib/pdf';
|
||||||
|
|
||||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
|
||||||
import { combineAudioChunks, withRetry } from '@/utils/audio';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties in the PDF context
|
* Interface defining all available methods and properties in the PDF context
|
||||||
|
|
@ -60,15 +64,19 @@ interface PDFContextType {
|
||||||
pdfText: string,
|
pdfText: string,
|
||||||
containerRef: RefObject<HTMLDivElement>,
|
containerRef: RefObject<HTMLDivElement>,
|
||||||
stopAndPlayFromIndex: (index: number) => void,
|
stopAndPlayFromIndex: (index: number) => void,
|
||||||
isProcessing: boolean
|
isProcessing: boolean,
|
||||||
|
enableHighlight?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
||||||
|
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
||||||
isAudioCombining: boolean;
|
isAudioCombining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the context
|
// Create the context
|
||||||
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const CONTINUATION_PREVIEW_CHARS = 600;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PDFProvider Component
|
* PDFProvider Component
|
||||||
*
|
*
|
||||||
|
|
@ -82,10 +90,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const {
|
const {
|
||||||
setText: setTTSText,
|
setText: setTTSText,
|
||||||
stop,
|
stop,
|
||||||
currDocPageNumber: currDocPage,
|
currDocPageNumber,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
setCurrDocPages,
|
setCurrDocPages,
|
||||||
setIsEPUB
|
setIsEPUB,
|
||||||
|
registerVisualPageChangeHandler,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const {
|
const {
|
||||||
headerMargin,
|
headerMargin,
|
||||||
|
|
@ -97,6 +106,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
voiceSpeed,
|
voiceSpeed,
|
||||||
voice,
|
voice,
|
||||||
ttsProvider,
|
ttsProvider,
|
||||||
|
ttsModel,
|
||||||
|
ttsInstructions,
|
||||||
|
smartSentenceSplitting,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
||||||
// Current document state
|
// Current document state
|
||||||
|
|
@ -104,7 +116,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
||||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
const [isAudioCombining] = useState(false);
|
||||||
|
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
||||||
|
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrDocPage(currDocPageNumber);
|
||||||
|
}, [currDocPageNumber]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles successful PDF document load
|
* Handles successful PDF document load
|
||||||
|
|
@ -126,22 +144,60 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const loadCurrDocText = useCallback(async () => {
|
const loadCurrDocText = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) return;
|
if (!pdfDocument) return;
|
||||||
const text = await extractTextFromPDF(pdfDocument, currDocPage, {
|
|
||||||
|
const margins = {
|
||||||
header: headerMargin,
|
header: headerMargin,
|
||||||
footer: footerMargin,
|
footer: footerMargin,
|
||||||
left: leftMargin,
|
left: leftMargin,
|
||||||
right: rightMargin
|
right: rightMargin
|
||||||
});
|
};
|
||||||
// Only update TTS text if the content has actually changed
|
|
||||||
// This prevents unnecessary resets of the sentence index
|
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
|
||||||
|
if (pageTextCacheRef.current.has(pageNumber)) {
|
||||||
|
const cached = pageTextCacheRef.current.get(pageNumber)!;
|
||||||
|
if (!shouldCache) {
|
||||||
|
pageTextCacheRef.current.delete(pageNumber);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = await extractTextFromPDF(pdfDocument, pageNumber, margins);
|
||||||
|
if (shouldCache) {
|
||||||
|
pageTextCacheRef.current.set(pageNumber, extracted);
|
||||||
|
}
|
||||||
|
return extracted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = currDocPages ?? pdfDocument.numPages;
|
||||||
|
const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined;
|
||||||
|
|
||||||
|
const [text, nextText] = await Promise.all([
|
||||||
|
getPageText(currDocPageNumber),
|
||||||
|
nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve<string | undefined>(undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
if (text !== currDocText || text === '') {
|
if (text !== currDocText || text === '') {
|
||||||
setCurrDocText(text);
|
setCurrDocText(text);
|
||||||
setTTSText(text);
|
setTTSText(text, {
|
||||||
|
location: currDocPageNumber,
|
||||||
|
nextLocation: nextPageNumber,
|
||||||
|
nextText: nextText?.slice(0, CONTINUATION_PREVIEW_CHARS),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading PDF text:', error);
|
console.error('Error loading PDF text:', error);
|
||||||
}
|
}
|
||||||
}, [pdfDocument, currDocPage, setTTSText, currDocText, headerMargin, footerMargin, leftMargin, rightMargin]);
|
}, [
|
||||||
|
pdfDocument,
|
||||||
|
currDocPageNumber,
|
||||||
|
currDocPages,
|
||||||
|
setTTSText,
|
||||||
|
currDocText,
|
||||||
|
headerMargin,
|
||||||
|
footerMargin,
|
||||||
|
leftMargin,
|
||||||
|
rightMargin,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook to update document text when the page changes
|
* Effect hook to update document text when the page changes
|
||||||
|
|
@ -151,7 +207,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
if (currDocData) {
|
if (currDocData) {
|
||||||
loadCurrDocText();
|
loadCurrDocText();
|
||||||
}
|
}
|
||||||
}, [currDocPage, currDocData, loadCurrDocText]);
|
}, [currDocPageNumber, currDocData, loadCurrDocText]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the current document based on its ID
|
* Sets the current document based on its ID
|
||||||
|
|
@ -162,7 +218,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
*/
|
*/
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const doc = await indexedDBService.getDocument(id);
|
const doc = await getPdfDocument(id);
|
||||||
if (doc) {
|
if (doc) {
|
||||||
setCurrDocName(doc.name);
|
setCurrDocName(doc.name);
|
||||||
setCurrDocData(doc.data);
|
setCurrDocData(doc.data);
|
||||||
|
|
@ -182,6 +238,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
setCurrDocText(undefined);
|
setCurrDocText(undefined);
|
||||||
setCurrDocPages(undefined);
|
setCurrDocPages(undefined);
|
||||||
setPdfDocument(undefined);
|
setPdfDocument(undefined);
|
||||||
|
pageTextCacheRef.current.clear();
|
||||||
stop();
|
stop();
|
||||||
}, [setCurrDocPages, stop]);
|
}, [setCurrDocPages, stop]);
|
||||||
|
|
||||||
|
|
@ -189,14 +246,16 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
|
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
|
||||||
* @param {Function} onProgress - Callback for progress updates
|
* @param {Function} onProgress - Callback for progress updates
|
||||||
* @param {AbortSignal} signal - Optional signal for cancellation
|
* @param {AbortSignal} signal - Optional signal for cancellation
|
||||||
* @param {string} format - Optional format for the audiobook ('mp3' or 'm4b')
|
* @param {Function} onChapterComplete - Optional callback for when a chapter completes
|
||||||
* @returns {Promise<ArrayBuffer>} The complete audiobook as an ArrayBuffer
|
* @returns {Promise<string>} The bookId for the generated audiobook
|
||||||
*/
|
*/
|
||||||
const createFullAudioBook = useCallback(async (
|
const createFullAudioBook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||||
|
providedBookId?: string,
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
format: 'mp3' | 'm4b' = 'mp3'
|
||||||
): Promise<ArrayBuffer> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) {
|
if (!pdfDocument) {
|
||||||
throw new Error('No PDF document loaded');
|
throw new Error('No PDF document loaded');
|
||||||
|
|
@ -207,16 +266,20 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
let totalLength = 0;
|
let totalLength = 0;
|
||||||
|
|
||||||
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
||||||
const text = await extractTextFromPDF(pdfDocument, pageNum, {
|
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||||
header: headerMargin,
|
header: headerMargin,
|
||||||
footer: footerMargin,
|
footer: footerMargin,
|
||||||
left: leftMargin,
|
left: leftMargin,
|
||||||
right: rightMargin
|
right: rightMargin
|
||||||
});
|
});
|
||||||
const trimmedText = text.trim();
|
const trimmedText = rawText.trim();
|
||||||
if (trimmedText) {
|
if (trimmedText) {
|
||||||
textPerPage.push(trimmedText);
|
const processedText = smartSentenceSplitting
|
||||||
totalLength += trimmedText.length;
|
? processTextToSentences(trimmedText).join(' ')
|
||||||
|
: trimmedText;
|
||||||
|
|
||||||
|
textPerPage.push(processedText);
|
||||||
|
totalLength += processedText.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,34 +287,85 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
throw new Error('No text content found in PDF');
|
throw new Error('No text content found in PDF');
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
|
|
||||||
let processedLength = 0;
|
let processedLength = 0;
|
||||||
let currentTime = 0;
|
let bookId: string = providedBookId || '';
|
||||||
|
|
||||||
|
// If we have a bookId, check for existing chapters to determine which indices already exist
|
||||||
|
const existingIndices = new Set<number>();
|
||||||
|
if (bookId) {
|
||||||
|
try {
|
||||||
|
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
if (existingResponse.ok) {
|
||||||
|
const existingData = await existingResponse.json();
|
||||||
|
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||||
|
for (const ch of existingData.chapters) {
|
||||||
|
existingIndices.add(ch.index);
|
||||||
|
}
|
||||||
|
let nextMissing = 0;
|
||||||
|
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||||
|
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking existing chapters:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Second pass: process text into audio
|
// Second pass: process text into audio
|
||||||
for (let i = 0; i < textPerPage.length; i++) {
|
for (let i = 0; i < textPerPage.length; i++) {
|
||||||
|
// Check for abort at the start of iteration
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
console.log('Generation cancelled by user');
|
||||||
return partialBuffer;
|
if (bookId) {
|
||||||
|
return bookId; // Return bookId with partial progress
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = textPerPage[i];
|
const text = textPerPage[i];
|
||||||
|
|
||||||
|
// Skip pages that already exist on disk (supports non-contiguous indices)
|
||||||
|
if (existingIndices.has(i)) {
|
||||||
|
processedLength += text.length;
|
||||||
|
onProgress((processedLength / totalLength) * 100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-openai-key': apiKey,
|
||||||
|
'x-openai-base-url': baseUrl,
|
||||||
|
'x-tts-provider': ttsProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqBody: TTSRequestPayload = {
|
||||||
|
text,
|
||||||
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
|
speed: voiceSpeed,
|
||||||
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryOptions: TTSRetryOptions = {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 1000,
|
||||||
|
maxDelay: 5000,
|
||||||
|
backoffFactor: 2
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audioBuffer = await withRetry(
|
const audioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
// Check for abort before starting TTS request
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: reqHeaders,
|
||||||
'x-openai-key': apiKey,
|
body: JSON.stringify(reqBody),
|
||||||
'x-openai-base-url': baseUrl,
|
|
||||||
'x-tts-provider': ttsProvider,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
text,
|
|
||||||
voice: voice,
|
|
||||||
speed: voiceSpeed,
|
|
||||||
format: format === 'm4b' ? 'aac' : 'mp3'
|
|
||||||
}),
|
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -265,51 +379,251 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
return buffer;
|
return buffer;
|
||||||
},
|
},
|
||||||
{
|
retryOptions
|
||||||
maxRetries: 3,
|
|
||||||
initialDelay: 1000,
|
|
||||||
maxDelay: 5000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
audioChunks.push({
|
const chapterTitle = `Page ${i + 1}`;
|
||||||
buffer: audioBuffer,
|
|
||||||
title: `Page ${i + 1}`,
|
// Check for abort before sending to server
|
||||||
startTime: currentTime
|
if (signal?.aborted) {
|
||||||
|
console.log('Generation cancelled before saving page');
|
||||||
|
if (bookId) {
|
||||||
|
return bookId;
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server for conversion and storage
|
||||||
|
const convertResponse = await fetch('/api/audio/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapterTitle,
|
||||||
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
chapterIndex: i
|
||||||
|
}),
|
||||||
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a small pause between pages (1s of silence)
|
if (convertResponse.status === 499) {
|
||||||
const silenceBuffer = new ArrayBuffer(48000);
|
throw new Error('cancelled');
|
||||||
audioChunks.push({
|
}
|
||||||
buffer: silenceBuffer,
|
|
||||||
startTime: currentTime + (audioBuffer.byteLength / 48000)
|
if (!convertResponse.ok) {
|
||||||
});
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
|
||||||
|
|
||||||
|
if (!bookId) {
|
||||||
|
bookId = returnedBookId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify about completed chapter
|
||||||
|
if (onChapterComplete) {
|
||||||
|
onChapterComplete({
|
||||||
|
index: chapterIndex,
|
||||||
|
title: chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
currentTime += (audioBuffer.byteLength + 48000) / 48000;
|
|
||||||
processedLength += text.length;
|
processedLength += text.length;
|
||||||
onProgress((processedLength / totalLength) * 100);
|
onProgress((processedLength / totalLength) * 100);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
console.log('TTS request aborted');
|
console.log('TTS request aborted, returning partial progress');
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
if (bookId) {
|
||||||
return partialBuffer;
|
return bookId; // Return with partial progress
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
console.error('Error processing page:', error);
|
console.error('Error processing page:', error);
|
||||||
|
|
||||||
|
// Notify about error
|
||||||
|
if (onChapterComplete) {
|
||||||
|
onChapterComplete({
|
||||||
|
index: i,
|
||||||
|
title: `Page ${i + 1}`,
|
||||||
|
status: 'error',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioChunks.length === 0) {
|
if (!bookId) {
|
||||||
throw new Error('No audio was generated from the PDF content');
|
throw new Error('No audio was generated from the PDF content');
|
||||||
}
|
}
|
||||||
|
|
||||||
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
return bookId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
|
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions, smartSentenceSplitting]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||||
|
*/
|
||||||
|
const regenerateChapter = useCallback(async (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
||||||
|
try {
|
||||||
|
if (!pdfDocument) {
|
||||||
|
throw new Error('No PDF document loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Chapter indices are based on non-empty pages used during generation.
|
||||||
|
// Build a mapping of "chapterIndex" -> actual PDF page number (1-based).
|
||||||
|
const nonEmptyPages: number[] = [];
|
||||||
|
for (let page = 1; page <= pdfDocument.numPages; page++) {
|
||||||
|
const pageText = await extractTextFromPDF(pdfDocument, page, {
|
||||||
|
header: headerMargin,
|
||||||
|
footer: footerMargin,
|
||||||
|
left: leftMargin,
|
||||||
|
right: rightMargin
|
||||||
|
});
|
||||||
|
if (pageText.trim()) {
|
||||||
|
nonEmptyPages.push(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chapterIndex < 0 || chapterIndex >= nonEmptyPages.length) {
|
||||||
|
throw new Error('Invalid chapter index');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNum = nonEmptyPages[chapterIndex];
|
||||||
|
|
||||||
|
// Extract text from the mapped page
|
||||||
|
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||||
|
header: headerMargin,
|
||||||
|
footer: footerMargin,
|
||||||
|
left: leftMargin,
|
||||||
|
right: rightMargin
|
||||||
|
});
|
||||||
|
|
||||||
|
const trimmedText = rawText.trim();
|
||||||
|
if (!trimmedText) {
|
||||||
|
throw new Error('No text content found on page');
|
||||||
|
}
|
||||||
|
|
||||||
|
const textForTTS = smartSentenceSplitting
|
||||||
|
? processTextToSentences(trimmedText).join(' ')
|
||||||
|
: trimmedText;
|
||||||
|
|
||||||
|
// Use logical chapter numbering (index + 1) to match original generation titles
|
||||||
|
const chapterTitle = `Page ${chapterIndex + 1}`;
|
||||||
|
|
||||||
|
// Generate audio with retry logic
|
||||||
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-openai-key': apiKey,
|
||||||
|
'x-openai-base-url': baseUrl,
|
||||||
|
'x-tts-provider': ttsProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reqBody: TTSRequestPayload = {
|
||||||
|
text: textForTTS,
|
||||||
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
|
speed: voiceSpeed,
|
||||||
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryOptions: TTSRetryOptions = {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 1000,
|
||||||
|
maxDelay: 5000,
|
||||||
|
backoffFactor: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const audioBuffer = await withRetry(
|
||||||
|
async () => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: reqHeaders,
|
||||||
|
body: JSON.stringify(reqBody),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ttsResponse.ok) {
|
||||||
|
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await ttsResponse.arrayBuffer();
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
throw new Error('Received empty audio buffer from TTS');
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
},
|
||||||
|
retryOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error('Page regeneration cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server for conversion and storage
|
||||||
|
const convertResponse = await fetch('/api/audio/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapterTitle,
|
||||||
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
chapterIndex
|
||||||
|
}),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (convertResponse.status === 499) {
|
||||||
|
throw new Error('cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!convertResponse.ok) {
|
||||||
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: returnedIndex,
|
||||||
|
title: chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
|
throw new Error('Page regeneration cancelled');
|
||||||
|
}
|
||||||
|
console.error('Error regenerating page:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions, smartSentenceSplitting]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook to initialize TTS as non-EPUB mode
|
* Effect hook to initialize TTS as non-EPUB mode
|
||||||
|
|
@ -318,6 +632,16 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
setIsEPUB(false);
|
setIsEPUB(false);
|
||||||
}, [setIsEPUB]);
|
}, [setIsEPUB]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerVisualPageChangeHandler(location => {
|
||||||
|
if (typeof location !== 'number') return;
|
||||||
|
if (!pdfDocument) return;
|
||||||
|
const totalPages = currDocPages ?? pdfDocument.numPages;
|
||||||
|
const clamped = Math.min(Math.max(location, 1), totalPages);
|
||||||
|
setCurrDocPage(clamped);
|
||||||
|
});
|
||||||
|
}, [registerVisualPageChangeHandler, currDocPages, pdfDocument]);
|
||||||
|
|
||||||
// Context value memoization
|
// Context value memoization
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -334,6 +658,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
handleTextClick,
|
handleTextClick,
|
||||||
pdfDocument,
|
pdfDocument,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
isAudioCombining,
|
isAudioCombining,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
@ -347,6 +672,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
pdfDocument,
|
pdfDocument,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
isAudioCombining,
|
isAudioCombining,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -371,4 +697,4 @@ export function usePDF() {
|
||||||
throw new Error('usePDF must be used within a PDFProvider');
|
throw new Error('usePDF must be used within a PDFProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,20 @@ import { useAudioCache } from '@/hooks/audio/useAudioCache';
|
||||||
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
||||||
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||||||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||||||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/utils/indexedDB';
|
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
|
||||||
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
||||||
import { withRetry } from '@/utils/audio';
|
import { withRetry } from '@/utils/audio';
|
||||||
import { processTextToSentences } from '@/utils/nlp';
|
import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
|
||||||
|
import { isKokoroModel } from '@/utils/voice';
|
||||||
|
import type {
|
||||||
|
TTSLocation,
|
||||||
|
TTSSmartMergeResult,
|
||||||
|
TTSPageTurnEstimate,
|
||||||
|
TTSPlaybackState,
|
||||||
|
TTSRequestPayload,
|
||||||
|
TTSRequestHeaders,
|
||||||
|
TTSRetryOptions,
|
||||||
|
} from '@/types/tts';
|
||||||
|
|
||||||
// Media globals
|
// Media globals
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -49,18 +59,7 @@ declare global {
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties in the TTS context
|
* Interface defining all available methods and properties in the TTS context
|
||||||
*/
|
*/
|
||||||
interface TTSContextType {
|
interface TTSContextType extends TTSPlaybackState {
|
||||||
// Playback state
|
|
||||||
isPlaying: boolean;
|
|
||||||
isProcessing: boolean;
|
|
||||||
currentSentence: string;
|
|
||||||
isBackgrounded: boolean; // Add this new property
|
|
||||||
|
|
||||||
// Navigation
|
|
||||||
currDocPage: string | number; // Change this to allow both types
|
|
||||||
currDocPageNumber: number; // For PDF
|
|
||||||
currDocPages: number | undefined;
|
|
||||||
|
|
||||||
// Voice settings
|
// Voice settings
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
|
|
||||||
|
|
@ -71,16 +70,174 @@ interface TTSContextType {
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
stopAndPlayFromIndex: (index: number) => void;
|
stopAndPlayFromIndex: (index: number) => void;
|
||||||
setText: (text: string, shouldPause?: boolean) => void;
|
setText: (text: string, options?: boolean | SetTextOptions) => void;
|
||||||
setCurrDocPages: (num: number | undefined) => void;
|
setCurrDocPages: (num: number | undefined) => void;
|
||||||
setSpeedAndRestart: (speed: number) => void;
|
setSpeedAndRestart: (speed: number) => void;
|
||||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
skipToLocation: (location: string | number, shouldPause?: boolean) => void;
|
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
|
||||||
registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation
|
registerLocationChangeHandler: (handler: (location: TTSLocation) => void) => void; // EPUB-only: Handles chapter navigation
|
||||||
|
registerVisualPageChangeHandler: (handler: (location: TTSLocation) => void) => void;
|
||||||
setIsEPUB: (isEPUB: boolean) => void;
|
setIsEPUB: (isEPUB: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SetTextOptions {
|
||||||
|
shouldPause?: boolean;
|
||||||
|
location?: TTSLocation;
|
||||||
|
nextLocation?: TTSLocation;
|
||||||
|
nextText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTINUATION_LOOKAHEAD = 600;
|
||||||
|
const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
|
||||||
|
|
||||||
|
const normalizeLocationKey = (location: TTSLocation) =>
|
||||||
|
typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
||||||
|
|
||||||
|
const isWhitespaceChar = (char: string) => /\s/.test(char);
|
||||||
|
|
||||||
|
const skipWhitespace = (source: string, start: number) => {
|
||||||
|
let index = start;
|
||||||
|
while (index < source.length && isWhitespaceChar(source[index])) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchNormalizedPrefixLength = (text: string, prefix: string): number | null => {
|
||||||
|
let textIndex = 0;
|
||||||
|
let prefixIndex = 0;
|
||||||
|
|
||||||
|
while (prefixIndex < prefix.length) {
|
||||||
|
const prefixChar = prefix[prefixIndex];
|
||||||
|
|
||||||
|
if (isWhitespaceChar(prefixChar)) {
|
||||||
|
prefixIndex = skipWhitespace(prefix, prefixIndex);
|
||||||
|
textIndex = skipWhitespace(text, textIndex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textIndex >= text.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textChar = text[textIndex];
|
||||||
|
|
||||||
|
if (textChar === prefixChar || textChar.toLowerCase() === prefixChar.toLowerCase()) {
|
||||||
|
textIndex++;
|
||||||
|
prefixIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return textIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsSentenceContinuation = (text: string) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !SENTENCE_ENDING.test(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripContinuationPrefix = (text: string, prefix: string) => {
|
||||||
|
if (!prefix) return { text, removed: false };
|
||||||
|
|
||||||
|
// Try literal match first since PDF text is normalized already
|
||||||
|
if (text.startsWith(prefix)) {
|
||||||
|
return {
|
||||||
|
text: text.slice(prefix.length).trimStart(),
|
||||||
|
removed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedPrefix = prefix.trimStart();
|
||||||
|
const trimmedText = text.trimStart();
|
||||||
|
|
||||||
|
if (trimmedText.startsWith(trimmedPrefix)) {
|
||||||
|
const offset = text.length - trimmedText.length;
|
||||||
|
return {
|
||||||
|
text: text.slice(offset + trimmedPrefix.length).trimStart(),
|
||||||
|
removed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedLength = matchNormalizedPrefixLength(text, prefix);
|
||||||
|
if (matchedLength !== null) {
|
||||||
|
return {
|
||||||
|
text: text.slice(matchedLength).trimStart(),
|
||||||
|
removed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text, removed: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null => {
|
||||||
|
if (!nextText?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippet = nextText.trim().slice(0, CONTINUATION_LOOKAHEAD);
|
||||||
|
let boundaryIndex = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < snippet.length; i++) {
|
||||||
|
const char = snippet[i];
|
||||||
|
if (/[.?!…]/.test(char)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < snippet.length && /["'”’)\]]/.test(snippet[j])) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
while (j < snippet.length && /\s/.test(snippet[j])) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
boundaryIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundaryIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSlice = snippet.slice(0, boundaryIndex);
|
||||||
|
const addition = rawSlice.trim();
|
||||||
|
|
||||||
|
if (!addition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: addition,
|
||||||
|
carried: rawSlice,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeContinuation = (text: string, nextText: string): TTSSmartMergeResult | null => {
|
||||||
|
if (!needsSentenceContinuation(text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slice = extractContinuationSlice(nextText);
|
||||||
|
if (!slice) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = text.trimEnd();
|
||||||
|
const endsWithHyphen = trimmed.endsWith('-');
|
||||||
|
const base = endsWithHyphen ? trimmed.slice(0, -1) : trimmed;
|
||||||
|
const joiner = endsWithHyphen ? '' : (base ? ' ' : '');
|
||||||
|
const mergedText = `${base}${joiner}${slice.text}`.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: mergedText,
|
||||||
|
carried: slice.carried,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Create the context
|
// Create the context
|
||||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
|
@ -106,15 +263,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsInstructions: configTTSInstructions,
|
ttsInstructions: configTTSInstructions,
|
||||||
updateConfigKey,
|
updateConfigKey,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
|
smartSentenceSplitting,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
||||||
// Remove OpenAI client reference as it's no longer needed
|
// Audio and voice management hooks
|
||||||
const audioContext = useAudioContext();
|
const audioContext = useAudioContext();
|
||||||
const audioCache = useAudioCache(25);
|
const audioCache = useAudioCache(25);
|
||||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
||||||
|
|
||||||
// Add ref for location change handler
|
// Add ref for location change handler
|
||||||
const locationChangeHandlerRef = useRef<((location: string | number) => void) | null>(null);
|
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||||||
|
const visualPageChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a handler function for location changes in EPUB documents
|
* Registers a handler function for location changes in EPUB documents
|
||||||
|
|
@ -122,10 +281,20 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
*
|
*
|
||||||
* @param {Function} handler - Function to handle location changes
|
* @param {Function} handler - Function to handle location changes
|
||||||
*/
|
*/
|
||||||
const registerLocationChangeHandler = useCallback((handler: (location: string | number) => void) => {
|
const registerLocationChangeHandler = useCallback((handler: (location: TTSLocation) => void) => {
|
||||||
locationChangeHandlerRef.current = handler;
|
locationChangeHandlerRef.current = handler;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a handler function for visual page changes in EPUB documents
|
||||||
|
* This is only used for EPUB documents to handle visual page navigation
|
||||||
|
*
|
||||||
|
* @param {Function} handler - Function to handle visual page changes
|
||||||
|
*/
|
||||||
|
const registerVisualPageChangeHandler = useCallback((handler: (location: TTSLocation) => void) => {
|
||||||
|
visualPageChangeHandlerRef.current = handler;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Get document ID from URL params
|
// Get document ID from URL params
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
|
|
@ -136,7 +305,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const [isEPUB, setIsEPUB] = useState(false);
|
const [isEPUB, setIsEPUB] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
const [currDocPage, setCurrDocPage] = useState<string | number>(1);
|
const [currDocPage, setCurrDocPage] = useState<TTSLocation>(1);
|
||||||
const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only
|
const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only
|
||||||
const [currDocPages, setCurrDocPages] = useState<number>();
|
const [currDocPages, setCurrDocPages] = useState<number>();
|
||||||
|
|
||||||
|
|
@ -155,6 +324,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
|
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
|
||||||
// Track if we're restoring from a saved position
|
// Track if we're restoring from a saved position
|
||||||
const [pendingRestoreIndex, setPendingRestoreIndex] = useState<number | null>(null);
|
const [pendingRestoreIndex, setPendingRestoreIndex] = useState<number | null>(null);
|
||||||
|
// Guard to coalesce rapid restarts and only resume the latest change
|
||||||
|
const restartSeqRef = useRef(0);
|
||||||
|
// Track continuation slices for PDF/EPUB page transitions
|
||||||
|
const continuationCarryRef = useRef<Map<string, string>>(new Map());
|
||||||
|
const epubContinuationRef = useRef<string | null>(null);
|
||||||
|
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
|
||||||
|
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes text into sentences using the shared NLP utility
|
* Processes text into sentences using the shared NLP utility
|
||||||
|
|
@ -178,20 +354,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const abortAudio = useCallback((clearPending = false) => {
|
const abortAudio = useCallback((clearPending = false) => {
|
||||||
if (activeHowl) {
|
if (activeHowl) {
|
||||||
activeHowl.stop();
|
activeHowl.stop();
|
||||||
activeHowl.unload(); // Ensure Howl instance is fully cleaned up
|
activeHowl.unload();
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clearPending) {
|
if (clearPending) {
|
||||||
// Abort all active TTS requests
|
|
||||||
console.log('Aborting active TTS requests');
|
|
||||||
activeAbortControllers.current.forEach(controller => {
|
activeAbortControllers.current.forEach(controller => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
});
|
});
|
||||||
activeAbortControllers.current.clear();
|
activeAbortControllers.current.clear();
|
||||||
// Clear any pending preload requests
|
|
||||||
preloadRequests.current.clear();
|
preloadRequests.current.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageTurnTimeoutRef.current) {
|
||||||
|
clearTimeout(pageTurnTimeoutRef.current);
|
||||||
|
pageTurnTimeoutRef.current = null;
|
||||||
|
}
|
||||||
}, [activeHowl]);
|
}, [activeHowl]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -208,9 +386,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
* Works for both PDF pages and EPUB locations
|
* Works for both PDF pages and EPUB locations
|
||||||
*
|
*
|
||||||
* @param {string | number} location - The target location to navigate to
|
* @param {string | number} location - The target location to navigate to
|
||||||
* @param {boolean} keepPlaying - Whether to maintain playback state
|
* @param {boolean} shouldPause - Whether to pause playback
|
||||||
*/
|
*/
|
||||||
const skipToLocation = useCallback((location: string | number, shouldPause = false) => {
|
const skipToLocation = useCallback((location: TTSLocation, shouldPause = false) => {
|
||||||
// Reset state for new content in correct order
|
// Reset state for new content in correct order
|
||||||
abortAudio();
|
abortAudio();
|
||||||
if (shouldPause) setIsPlaying(false);
|
if (shouldPause) setIsPlaying(false);
|
||||||
|
|
@ -230,7 +408,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
// Handle within current page bounds
|
// Handle within current page bounds
|
||||||
if (nextIndex < sentences.length && nextIndex >= 0) {
|
if (nextIndex < sentences.length && nextIndex >= 0) {
|
||||||
console.log('isEPUB', isEPUB!);
|
|
||||||
setCurrentIndex(nextIndex);
|
setCurrentIndex(nextIndex);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -294,9 +471,65 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
*
|
*
|
||||||
* @param {string} text - The text to be processed
|
* @param {string} text - The text to be processed
|
||||||
*/
|
*/
|
||||||
const setText = useCallback((text: string, shouldPause = false) => {
|
const setText = useCallback((text: string, options?: boolean | SetTextOptions) => {
|
||||||
// Check for blank section first
|
const normalizedOptions: SetTextOptions = typeof options === 'boolean'
|
||||||
if (handleBlankSection(text)) return;
|
? { shouldPause: options }
|
||||||
|
: (options || {});
|
||||||
|
|
||||||
|
let workingText = text;
|
||||||
|
|
||||||
|
// Apply or clear sentence continuation logic based on config
|
||||||
|
let continuationCarried: string | undefined;
|
||||||
|
if (smartSentenceSplitting) {
|
||||||
|
if (isEPUB && epubContinuationRef.current) {
|
||||||
|
const { text: strippedText, removed } = stripContinuationPrefix(workingText, epubContinuationRef.current);
|
||||||
|
workingText = strippedText;
|
||||||
|
if (removed) {
|
||||||
|
epubContinuationRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEPUB && normalizedOptions.location !== undefined) {
|
||||||
|
const key = normalizeLocationKey(normalizedOptions.location);
|
||||||
|
const carried = continuationCarryRef.current.get(key);
|
||||||
|
if (carried) {
|
||||||
|
const { text: strippedText, removed } = stripContinuationPrefix(workingText, carried);
|
||||||
|
workingText = strippedText;
|
||||||
|
if (removed) {
|
||||||
|
continuationCarryRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedOptions.nextText) {
|
||||||
|
const merged = mergeContinuation(workingText, normalizedOptions.nextText);
|
||||||
|
if (merged) {
|
||||||
|
workingText = merged.text;
|
||||||
|
continuationCarried = merged.carried;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (continuationCarried) {
|
||||||
|
if (isEPUB) {
|
||||||
|
epubContinuationRef.current = continuationCarried;
|
||||||
|
} else if (normalizedOptions.nextLocation !== undefined) {
|
||||||
|
continuationCarryRef.current.set(
|
||||||
|
normalizeLocationKey(normalizedOptions.nextLocation),
|
||||||
|
continuationCarried
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// When disabled, clear any stale continuation state
|
||||||
|
epubContinuationRef.current = null;
|
||||||
|
continuationCarryRef.current.clear();
|
||||||
|
pageTurnEstimateRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for blank section after adjustments
|
||||||
|
if (handleBlankSection(workingText)) return;
|
||||||
|
|
||||||
|
const shouldPause = normalizedOptions.shouldPause ?? false;
|
||||||
|
|
||||||
// Keep track of previous state and pause playback
|
// Keep track of previous state and pause playback
|
||||||
const wasPlaying = isPlaying;
|
const wasPlaying = isPlaying;
|
||||||
|
|
@ -304,8 +537,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
abortAudio(true); // Clear pending requests since text is changing
|
abortAudio(true); // Clear pending requests since text is changing
|
||||||
setIsProcessing(true); // Set processing state before text processing starts
|
setIsProcessing(true); // Set processing state before text processing starts
|
||||||
|
|
||||||
console.log('Setting text:', text);
|
processTextToSentencesLocal(workingText)
|
||||||
processTextToSentencesLocal(text)
|
|
||||||
.then(newSentences => {
|
.then(newSentences => {
|
||||||
if (newSentences.length === 0) {
|
if (newSentences.length === 0) {
|
||||||
console.warn('No sentences found in text');
|
console.warn('No sentences found in text');
|
||||||
|
|
@ -315,7 +547,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
// Set all state updates in a predictable order
|
// Set all state updates in a predictable order
|
||||||
setSentences(newSentences);
|
setSentences(newSentences);
|
||||||
|
|
||||||
// Check if we have a pending restore index for PDF
|
// Check if we have a pending restore index for PDF
|
||||||
if (pendingRestoreIndex !== null && !isEPUB) {
|
if (pendingRestoreIndex !== null && !isEPUB) {
|
||||||
const restoreIndex = Math.min(pendingRestoreIndex, newSentences.length - 1);
|
const restoreIndex = Math.min(pendingRestoreIndex, newSentences.length - 1);
|
||||||
|
|
@ -325,7 +557,42 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
} else {
|
} else {
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute auto page-turn estimate for PDFs when we have a continuation
|
||||||
|
if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) {
|
||||||
|
const continuationNormalized = preprocessSentenceForAudio(continuationCarried);
|
||||||
|
if (continuationNormalized) {
|
||||||
|
let bestEstimate: TTSPageTurnEstimate | null = null;
|
||||||
|
|
||||||
|
newSentences.forEach((sentence, index) => {
|
||||||
|
const normalizedSentence = preprocessSentenceForAudio(sentence);
|
||||||
|
if (!normalizedSentence) return;
|
||||||
|
|
||||||
|
if (!normalizedSentence.toLowerCase().endsWith(continuationNormalized.toLowerCase())) return;
|
||||||
|
|
||||||
|
const totalLength = normalizedSentence.length;
|
||||||
|
const continuationLength = continuationNormalized.length;
|
||||||
|
if (totalLength <= continuationLength) return;
|
||||||
|
|
||||||
|
const baseLength = totalLength - continuationLength;
|
||||||
|
const fraction = baseLength / totalLength;
|
||||||
|
if (fraction <= 0 || fraction >= 1) return;
|
||||||
|
|
||||||
|
bestEstimate = {
|
||||||
|
location: normalizedOptions.nextLocation!,
|
||||||
|
sentenceIndex: index,
|
||||||
|
fraction,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
pageTurnEstimateRef.current = bestEstimate;
|
||||||
|
} else {
|
||||||
|
pageTurnEstimateRef.current = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pageTurnEstimateRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|
||||||
// Restore playback state if needed
|
// Restore playback state if needed
|
||||||
|
|
@ -344,7 +611,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [isPlaying, handleBlankSection, abortAudio, processTextToSentencesLocal, pendingRestoreIndex, isEPUB]);
|
}, [isPlaying, handleBlankSection, abortAudio, processTextToSentencesLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the playback state between playing and paused
|
* Toggles the playback state between playing and paused
|
||||||
|
|
@ -412,19 +679,31 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (availableVoices.length > 0) {
|
if (availableVoices.length > 0) {
|
||||||
|
// Allow Kokoro multi-voice strings (e.g., "voice1(0.5)+voice2(0.5)") for any provider
|
||||||
|
const isKokoro = isKokoroModel(configTTSModel);
|
||||||
|
|
||||||
|
if (isKokoro) {
|
||||||
|
// If Kokoro and we have any voice string (including plus/weights), don't override it.
|
||||||
|
// Only default when voice is empty.
|
||||||
|
if (!voice) {
|
||||||
|
setVoice(availableVoices[0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!voice || !availableVoices.includes(voice)) {
|
if (!voice || !availableVoices.includes(voice)) {
|
||||||
console.log(`Voice "${voice || '(empty)'}" not found in available voices. Using "${availableVoices[0]}"`);
|
console.log(`Voice "${voice || '(empty)'}" not found in available voices. Using "${availableVoices[0]}"`);
|
||||||
setVoice(availableVoices[0]);
|
setVoice(availableVoices[0]);
|
||||||
// Don't save to config - just use it temporarily until user explicitly selects one
|
// Don't save to config - just use it temporarily until user explicitly selects one
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [availableVoices, voice]);
|
}, [availableVoices, voice, configTTSModel]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates and plays audio for the current sentence
|
* Generates and plays audio for the current sentence
|
||||||
*
|
*
|
||||||
* @param {string} sentence - The sentence to generate audio for
|
* @param {string} sentence - The sentence to generate audio for
|
||||||
* @returns {Promise<AudioBuffer | undefined>} The generated audio buffer
|
* @returns {Promise<ArrayBuffer | undefined>} The generated audio buffer
|
||||||
*/
|
*/
|
||||||
const getAudio = useCallback(async (sentence: string): Promise<ArrayBuffer | undefined> => {
|
const getAudio = useCallback(async (sentence: string): Promise<ArrayBuffer | undefined> => {
|
||||||
// Check if the audio is already cached
|
// Check if the audio is already cached
|
||||||
|
|
@ -441,23 +720,37 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
activeAbortControllers.current.add(controller);
|
activeAbortControllers.current.add(controller);
|
||||||
|
|
||||||
|
const reqHeaders: TTSRequestHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-openai-key': openApiKey || '',
|
||||||
|
'x-tts-provider': configTTSProvider,
|
||||||
|
};
|
||||||
|
if (openApiBaseUrl) {
|
||||||
|
reqHeaders['x-openai-base-url'] = openApiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqBody: TTSRequestPayload = {
|
||||||
|
text: sentence,
|
||||||
|
voice,
|
||||||
|
speed,
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryOptions: TTSRetryOptions = {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 1000,
|
||||||
|
maxDelay: 5000,
|
||||||
|
backoffFactor: 2
|
||||||
|
};
|
||||||
|
|
||||||
const arrayBuffer = await withRetry(
|
const arrayBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
||||||
const response = await fetch('/api/tts', {
|
const response = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: reqHeaders,
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(reqBody),
|
||||||
'x-openai-key': openApiKey || '',
|
|
||||||
'x-openai-base-url': openApiBaseUrl || '',
|
|
||||||
'x-tts-provider': configTTSProvider,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
text: sentence,
|
|
||||||
voice: voice,
|
|
||||||
speed: speed,
|
|
||||||
model: ttsModel,
|
|
||||||
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -467,12 +760,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
return response.arrayBuffer();
|
return response.arrayBuffer();
|
||||||
},
|
},
|
||||||
{
|
retryOptions
|
||||||
maxRetries: 3,
|
|
||||||
initialDelay: 1000,
|
|
||||||
maxDelay: 5000,
|
|
||||||
backoffFactor: 2
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove the controller once the request is complete
|
// Remove the controller once the request is complete
|
||||||
|
|
@ -532,7 +820,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
try {
|
try {
|
||||||
const audioBuffer = await getAudio(sentence);
|
const audioBuffer = await getAudio(sentence);
|
||||||
if (!audioBuffer) throw new Error('No audio data generated');
|
if (!audioBuffer) throw new Error('No audio data generated');
|
||||||
|
|
||||||
// Convert to base64 data URI
|
// Convert to base64 data URI
|
||||||
const bytes = new Uint8Array(audioBuffer);
|
const bytes = new Uint8Array(audioBuffer);
|
||||||
const binaryString = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), '');
|
const binaryString = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), '');
|
||||||
|
|
@ -561,7 +849,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
*
|
*
|
||||||
* @param {string} sentence - The sentence to play
|
* @param {string} sentence - The sentence to play
|
||||||
*/
|
*/
|
||||||
const playSentenceWithHowl = useCallback(async (sentence: string) => {
|
const playSentenceWithHowl = useCallback(async (sentence: string, sentenceIndex: number) => {
|
||||||
if (!sentence) {
|
if (!sentence) {
|
||||||
console.log('No sentence to play');
|
console.log('No sentence to play');
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|
@ -591,13 +879,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
preload: true,
|
preload: true,
|
||||||
pool: 5,
|
pool: 5,
|
||||||
rate: audioSpeed,
|
rate: audioSpeed,
|
||||||
|
onload: function (this: Howl) {
|
||||||
|
const estimate = pageTurnEstimateRef.current;
|
||||||
|
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
|
||||||
|
if (!visualPageChangeHandlerRef.current) return;
|
||||||
|
|
||||||
|
const duration = this.duration();
|
||||||
|
if (!duration || !Number.isFinite(duration)) return;
|
||||||
|
|
||||||
|
const delayMs = duration * estimate.fraction * 1000;
|
||||||
|
if (delayMs <= 0 || delayMs >= duration * 1000) return;
|
||||||
|
|
||||||
|
if (pageTurnTimeoutRef.current) {
|
||||||
|
clearTimeout(pageTurnTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageTurnTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
const currentEstimate = pageTurnEstimateRef.current;
|
||||||
|
if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return;
|
||||||
|
visualPageChangeHandlerRef.current?.(currentEstimate.location);
|
||||||
|
}, delayMs);
|
||||||
|
},
|
||||||
onplay: () => {
|
onplay: () => {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.playbackState = 'playing';
|
navigator.mediaSession.playbackState = 'playing';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onplayerror: function(this: Howl, error) {
|
onplayerror: function (this: Howl, error) {
|
||||||
console.warn('Howl playback error:', error);
|
console.warn('Howl playback error:', error);
|
||||||
// Try to recover by forcing HTML5 audio mode
|
// Try to recover by forcing HTML5 audio mode
|
||||||
if (this.state() === 'loaded') {
|
if (this.state() === 'loaded') {
|
||||||
|
|
@ -608,17 +918,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onloaderror: async function(this: Howl, error) {
|
onloaderror: async function (this: Howl, error) {
|
||||||
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
|
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
|
||||||
|
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
// Calculate exponential backoff delay
|
// Calculate exponential backoff delay
|
||||||
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
||||||
console.log(`Retrying in ${delay}ms...`);
|
console.log(`Retrying in ${delay}ms...`);
|
||||||
|
|
||||||
// Wait for the delay
|
// Wait for the delay
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
|
||||||
// Try to create a new Howl instance
|
// Try to create a new Howl instance
|
||||||
const retryHowl = await createHowl(retryCount + 1);
|
const retryHowl = await createHowl(retryCount + 1);
|
||||||
if (retryHowl) {
|
if (retryHowl) {
|
||||||
|
|
@ -631,7 +941,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
this.unload();
|
this.unload();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
|
||||||
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
||||||
id: 'audio-load-error',
|
id: 'audio-load-error',
|
||||||
style: {
|
style: {
|
||||||
|
|
@ -640,18 +950,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
},
|
},
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
advance();
|
advance();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onend: function(this: Howl) {
|
onend: function (this: Howl) {
|
||||||
this.unload();
|
this.unload();
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
|
if (pageTurnTimeoutRef.current) {
|
||||||
|
clearTimeout(pageTurnTimeoutRef.current);
|
||||||
|
pageTurnTimeoutRef.current = null;
|
||||||
|
}
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
advance();
|
advance();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onstop: function(this: Howl) {
|
onstop: function (this: Howl) {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
this.unload();
|
this.unload();
|
||||||
}
|
}
|
||||||
|
|
@ -690,7 +1004,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
||||||
|
|
||||||
const playAudio = useCallback(async () => {
|
const playAudio = useCallback(async () => {
|
||||||
const howl = await playSentenceWithHowl(sentences[currentIndex]);
|
const howl = await playSentenceWithHowl(sentences[currentIndex], currentIndex);
|
||||||
if (howl) {
|
if (howl) {
|
||||||
howl.play();
|
howl.play();
|
||||||
}
|
}
|
||||||
|
|
@ -762,6 +1076,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Cancel any ongoing request
|
// Cancel any ongoing request
|
||||||
abortAudio();
|
abortAudio();
|
||||||
locationChangeHandlerRef.current = null;
|
locationChangeHandlerRef.current = null;
|
||||||
|
epubContinuationRef.current = null;
|
||||||
|
continuationCarryRef.current.clear();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setSentences([]);
|
setSentences([]);
|
||||||
|
|
@ -791,6 +1107,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||||
const wasPlaying = isPlaying;
|
const wasPlaying = isPlaying;
|
||||||
|
|
||||||
|
// Bump restart sequence to invalidate older restarts
|
||||||
|
const mySeq = ++restartSeqRef.current;
|
||||||
|
|
||||||
// Set a flag to prevent double audio requests during config update
|
// Set a flag to prevent double audio requests during config update
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
|
@ -806,8 +1125,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Update config after state changes
|
// Update config after state changes
|
||||||
updateConfigKey('voiceSpeed', newSpeed).then(() => {
|
updateConfigKey('voiceSpeed', newSpeed).then(() => {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
// Resume playback if it was playing before
|
// Resume playback if it was playing before and this is the latest restart
|
||||||
if (wasPlaying) {
|
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -821,6 +1140,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
||||||
const wasPlaying = isPlaying;
|
const wasPlaying = isPlaying;
|
||||||
|
|
||||||
|
// Bump restart sequence to invalidate older restarts
|
||||||
|
const mySeq = ++restartSeqRef.current;
|
||||||
|
|
||||||
// Set a flag to prevent double audio requests during config update
|
// Set a flag to prevent double audio requests during config update
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
|
@ -836,8 +1158,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Update config after state changes
|
// Update config after state changes
|
||||||
updateConfigKey('voice', newVoice).then(() => {
|
updateConfigKey('voice', newVoice).then(() => {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
// Resume playback if it was playing before
|
// Resume playback if it was playing before and this is the latest restart
|
||||||
if (wasPlaying) {
|
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -851,6 +1173,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => {
|
const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||||
const wasPlaying = isPlaying;
|
const wasPlaying = isPlaying;
|
||||||
|
|
||||||
|
// Bump restart sequence to invalidate older restarts
|
||||||
|
const mySeq = ++restartSeqRef.current;
|
||||||
|
|
||||||
// Set a flag to prevent double audio requests during config update
|
// Set a flag to prevent double audio requests during config update
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
|
@ -865,8 +1190,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Update config after state changes
|
// Update config after state changes
|
||||||
updateConfigKey('audioPlayerSpeed', newSpeed).then(() => {
|
updateConfigKey('audioPlayerSpeed', newSpeed).then(() => {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
// Resume playback if it was playing before
|
// Resume playback if it was playing before and this is the latest restart
|
||||||
if (wasPlaying) {
|
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -897,6 +1222,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setVoiceAndRestart,
|
setVoiceAndRestart,
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
registerLocationChangeHandler,
|
registerLocationChangeHandler,
|
||||||
|
registerVisualPageChangeHandler,
|
||||||
setIsEPUB
|
setIsEPUB
|
||||||
}), [
|
}), [
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
|
@ -921,6 +1247,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setVoiceAndRestart,
|
setVoiceAndRestart,
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
registerLocationChangeHandler,
|
registerLocationChangeHandler,
|
||||||
|
registerVisualPageChangeHandler,
|
||||||
setIsEPUB
|
setIsEPUB
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -937,7 +1264,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
getLastDocumentLocation(id as string).then(lastLocation => {
|
getLastDocumentLocation(id as string).then(lastLocation => {
|
||||||
if (lastLocation) {
|
if (lastLocation) {
|
||||||
console.log('Setting last location:', lastLocation);
|
console.log('Setting last location:', lastLocation);
|
||||||
|
|
||||||
if (isEPUB && locationChangeHandlerRef.current) {
|
if (isEPUB && locationChangeHandlerRef.current) {
|
||||||
// For EPUB documents, use the location change handler
|
// For EPUB documents, use the location change handler
|
||||||
locationChangeHandlerRef.current(lastLocation);
|
locationChangeHandlerRef.current(lastLocation);
|
||||||
|
|
@ -947,7 +1274,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
|
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
|
||||||
const page = parseInt(pageStr, 10);
|
const page = parseInt(pageStr, 10);
|
||||||
const sentenceIndex = parseInt(sentenceIndexStr, 10);
|
const sentenceIndex = parseInt(sentenceIndexStr, 10);
|
||||||
|
|
||||||
if (!isNaN(page) && !isNaN(sentenceIndex)) {
|
if (!isNaN(page) && !isNaN(sentenceIndex)) {
|
||||||
console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`);
|
console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`);
|
||||||
// Skip to the page first, then the sentence index will be restored when setText is called
|
// Skip to the page first, then the sentence index will be restored when setText is called
|
||||||
|
|
@ -994,7 +1321,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to consume the TTS context
|
* Custom hook to consume the TTS context
|
||||||
* Ensures the context is used within a provider
|
* Ensures the context is used within a TTSProvider
|
||||||
*
|
*
|
||||||
* @throws {Error} If used outside of TTSProvider
|
* @throws {Error} If used outside of TTSProvider
|
||||||
* @returns {TTSContextType} The TTS context value
|
* @returns {TTSContextType} The TTS context value
|
||||||
|
|
@ -1005,4 +1332,4 @@ export function useTTS() {
|
||||||
throw new Error('useTTS must be used within a TTSProvider');
|
throw new Error('useTTS must be used within a TTSProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,27 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { indexedDBService } from '@/utils/indexedDB';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db } from '@/lib/dexie';
|
||||||
import type { EPUBDocument } from '@/types/documents';
|
import type { EPUBDocument } from '@/types/documents';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
|
||||||
|
|
||||||
export function useEPUBDocuments() {
|
export function useEPUBDocuments() {
|
||||||
const { isDBReady } = useConfig();
|
const documents = useLiveQuery(
|
||||||
const [documents, setDocuments] = useState<EPUBDocument[]>([]);
|
() => db['epub-documents'].toArray(),
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
[],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const loadDocuments = useCallback(async () => {
|
const isLoading = documents === undefined;
|
||||||
if (isDBReady) {
|
|
||||||
try {
|
|
||||||
const docs = await indexedDBService.getAllEPUBDocuments();
|
|
||||||
setDocuments(docs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load EPUB documents:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isDBReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDocuments();
|
|
||||||
}, [loadDocuments]);
|
|
||||||
|
|
||||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
console.log('Original file size:', file.size);
|
console.log('Original file size:', file.size);
|
||||||
console.log('ArrayBuffer size:', arrayBuffer.byteLength);
|
console.log('ArrayBuffer size:', arrayBuffer.byteLength);
|
||||||
|
|
||||||
const newDoc: EPUBDocument = {
|
const newDoc: EPUBDocument = {
|
||||||
id,
|
id,
|
||||||
type: 'epub',
|
type: 'epub',
|
||||||
|
|
@ -44,56 +31,23 @@ export function useEPUBDocuments() {
|
||||||
data: arrayBuffer,
|
data: arrayBuffer,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
await db['epub-documents'].add(newDoc);
|
||||||
await indexedDBService.addEPUBDocument(newDoc);
|
return id;
|
||||||
setDocuments((prev) => [...prev, newDoc]);
|
|
||||||
return id;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add EPUB document:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
await db['epub-documents'].delete(id);
|
||||||
await indexedDBService.removeEPUBDocument(id);
|
|
||||||
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to remove EPUB document:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (isDBReady) {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const docs = await indexedDBService.getAllEPUBDocuments();
|
|
||||||
setDocuments(docs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh documents:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isDBReady]);
|
|
||||||
|
|
||||||
const clearDocuments = useCallback(async (): Promise<void> => {
|
const clearDocuments = useCallback(async (): Promise<void> => {
|
||||||
try {
|
await db['epub-documents'].clear();
|
||||||
await indexedDBService.clearEPUBDocuments();
|
|
||||||
setDocuments([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear EPUB documents:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents: documents ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
addDocument,
|
addDocument,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
refresh,
|
clearDocuments,
|
||||||
clearDocuments, // Add clearDocuments to return value
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, RefObject, useState } from 'react';
|
import { useEffect, RefObject, useState } from 'react';
|
||||||
import { debounce } from '@/utils/pdf';
|
import { debounce } from '@/lib/pdf';
|
||||||
|
|
||||||
export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
|
export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const getThemeStyles = (): IReactReaderStyle => {
|
||||||
readerArea: {
|
readerArea: {
|
||||||
...baseStyle.readerArea,
|
...baseStyle.readerArea,
|
||||||
backgroundColor: colors.base,
|
backgroundColor: colors.base,
|
||||||
|
height: '100%',
|
||||||
},
|
},
|
||||||
titleArea: {
|
titleArea: {
|
||||||
...baseStyle.titleArea,
|
...baseStyle.titleArea,
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,19 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { indexedDBService } from '@/utils/indexedDB';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db } from '@/lib/dexie';
|
||||||
import type { HTMLDocument } from '@/types/documents';
|
import type { HTMLDocument } from '@/types/documents';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
|
||||||
|
|
||||||
export function useHTMLDocuments() {
|
export function useHTMLDocuments() {
|
||||||
const { isDBReady } = useConfig();
|
const documents = useLiveQuery(
|
||||||
const [documents, setDocuments] = useState<HTMLDocument[]>([]);
|
() => db['html-documents'].toArray(),
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
[],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const loadDocuments = useCallback(async () => {
|
const isLoading = documents === undefined;
|
||||||
if (isDBReady) {
|
|
||||||
try {
|
|
||||||
const docs = await indexedDBService.getAllHTMLDocuments();
|
|
||||||
setDocuments(docs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load HTML documents:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isDBReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDocuments();
|
|
||||||
}, [loadDocuments]);
|
|
||||||
|
|
||||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
|
|
@ -41,56 +28,23 @@ export function useHTMLDocuments() {
|
||||||
data: content,
|
data: content,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
await db['html-documents'].add(newDoc);
|
||||||
await indexedDBService.addHTMLDocument(newDoc);
|
return id;
|
||||||
setDocuments((prev) => [...prev, newDoc]);
|
|
||||||
return id;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add HTML document:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
await db['html-documents'].delete(id);
|
||||||
await indexedDBService.removeHTMLDocument(id);
|
|
||||||
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to remove HTML document:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (isDBReady) {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const docs = await indexedDBService.getAllHTMLDocuments();
|
|
||||||
setDocuments(docs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh documents:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isDBReady]);
|
|
||||||
|
|
||||||
const clearDocuments = useCallback(async (): Promise<void> => {
|
const clearDocuments = useCallback(async (): Promise<void> => {
|
||||||
try {
|
await db['html-documents'].clear();
|
||||||
await indexedDBService.clearHTMLDocuments();
|
|
||||||
setDocuments([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear HTML documents:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents: documents ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
addDocument,
|
addDocument,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
refresh,
|
|
||||||
clearDocuments,
|
clearDocuments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,24 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { indexedDBService } from '@/utils/indexedDB';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
import { db } from '@/lib/dexie';
|
||||||
import type { PDFDocument } from '@/types/documents';
|
import type { PDFDocument } from '@/types/documents';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
|
||||||
|
|
||||||
export function usePDFDocuments() {
|
export function usePDFDocuments() {
|
||||||
const { isDBReady } = useConfig();
|
const documents = useLiveQuery(
|
||||||
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
() => db['pdf-documents'].toArray(),
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
[],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
const isLoading = documents === undefined;
|
||||||
* Load documents from IndexedDB when the database is ready
|
|
||||||
*/
|
|
||||||
const loadDocuments = useCallback(async () => {
|
|
||||||
if (isDBReady) {
|
|
||||||
try {
|
|
||||||
const docs = await indexedDBService.getAllDocuments();
|
|
||||||
setDocuments(docs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load documents:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isDBReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDocuments();
|
|
||||||
}, [loadDocuments]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new document to IndexedDB
|
|
||||||
*
|
|
||||||
* @param {File} file - The PDF file to add
|
|
||||||
* @returns {Promise<string>} The ID of the added document
|
|
||||||
*/
|
|
||||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
const newDoc: PDFDocument = {
|
const newDoc: PDFDocument = {
|
||||||
id,
|
id,
|
||||||
type: 'pdf',
|
type: 'pdf',
|
||||||
|
|
@ -50,61 +28,23 @@ export function usePDFDocuments() {
|
||||||
data: arrayBuffer,
|
data: arrayBuffer,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
await db['pdf-documents'].add(newDoc);
|
||||||
await indexedDBService.addDocument(newDoc);
|
return id;
|
||||||
setDocuments((prev) => [...prev, newDoc]);
|
|
||||||
return id;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add document:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a document from IndexedDB
|
|
||||||
*
|
|
||||||
* @param {string} id - The ID of the document to remove
|
|
||||||
*/
|
|
||||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
await db['pdf-documents'].delete(id);
|
||||||
await indexedDBService.removeDocument(id);
|
|
||||||
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to remove document:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (isDBReady) {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const docs = await indexedDBService.getAllDocuments();
|
|
||||||
setDocuments(docs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh documents:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isDBReady]);
|
|
||||||
|
|
||||||
const clearDocuments = useCallback(async (): Promise<void> => {
|
const clearDocuments = useCallback(async (): Promise<void> => {
|
||||||
try {
|
await db['pdf-documents'].clear();
|
||||||
await indexedDBService.clearPDFDocuments();
|
|
||||||
setDocuments([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear PDF documents:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents: documents ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
addDocument,
|
addDocument,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
refresh,
|
clearDocuments,
|
||||||
clearDocuments, // Add clearDocuments to return value
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { RefObject, useState, useEffect } from 'react';
|
import { RefObject, useState, useEffect } from 'react';
|
||||||
import { debounce } from '@/utils/pdf';
|
import { debounce } from '@/lib/pdf';
|
||||||
|
|
||||||
interface UsePDFResizeResult {
|
interface UsePDFResizeResult {
|
||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,12 @@ export function useTimeEstimation(): TimeEstimation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastProgressUpdateRef.current = currentTime;
|
|
||||||
|
|
||||||
if (shouldSkipUpdate) {
|
if (shouldSkipUpdate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastProgressUpdateRef.current = currentTime;
|
||||||
|
|
||||||
const history = progressHistoryRef.current;
|
const history = progressHistoryRef.current;
|
||||||
|
|
||||||
if (history.length < 2) {
|
if (history.length < 2) {
|
||||||
|
|
|
||||||
551
src/lib/dexie.ts
Normal file
551
src/lib/dexie.ts
Normal file
|
|
@ -0,0 +1,551 @@
|
||||||
|
import Dexie, { type EntityTable } from 'dexie';
|
||||||
|
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
|
||||||
|
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents';
|
||||||
|
|
||||||
|
const DB_NAME = 'openreader-db';
|
||||||
|
// Managed via Dexie (version bumped from the original manual IndexedDB)
|
||||||
|
const DB_VERSION = 5;
|
||||||
|
|
||||||
|
const PDF_TABLE = 'pdf-documents' as const;
|
||||||
|
const EPUB_TABLE = 'epub-documents' as const;
|
||||||
|
const HTML_TABLE = 'html-documents' as const;
|
||||||
|
const CONFIG_TABLE = 'config' as const;
|
||||||
|
const APP_CONFIG_TABLE = 'app-config' as const;
|
||||||
|
const LAST_LOCATION_TABLE = 'last-locations' as const;
|
||||||
|
|
||||||
|
export interface LastLocationRow {
|
||||||
|
docId: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigRow {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenReaderDB = Dexie & {
|
||||||
|
[PDF_TABLE]: EntityTable<PDFDocument, 'id'>;
|
||||||
|
[EPUB_TABLE]: EntityTable<EPUBDocument, 'id'>;
|
||||||
|
[HTML_TABLE]: EntityTable<HTMLDocument, 'id'>;
|
||||||
|
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
|
||||||
|
[APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>;
|
||||||
|
[LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const db = new Dexie(DB_NAME) as OpenReaderDB;
|
||||||
|
|
||||||
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
|
const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||||
|
openai: 'https://api.openai.com/v1',
|
||||||
|
deepinfra: 'https://api.deepinfra.com/v1/openai',
|
||||||
|
'custom-openai': '',
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawConfigMap = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
function inferProviderAndBaseUrl(raw: RawConfigMap): { provider: string; baseUrl: string } {
|
||||||
|
const cachedApiKey = raw.apiKey;
|
||||||
|
const cachedBaseUrl = raw.baseUrl;
|
||||||
|
let inferredProvider = raw.ttsProvider || '';
|
||||||
|
|
||||||
|
if (!isDev && !raw.ttsProvider) {
|
||||||
|
inferredProvider = 'deepinfra';
|
||||||
|
} else if (!inferredProvider) {
|
||||||
|
if (cachedBaseUrl) {
|
||||||
|
const baseUrlLower = cachedBaseUrl.toLowerCase();
|
||||||
|
if (baseUrlLower.includes('deepinfra.com')) {
|
||||||
|
inferredProvider = 'deepinfra';
|
||||||
|
} else if (baseUrlLower.includes('openai.com')) {
|
||||||
|
inferredProvider = 'openai';
|
||||||
|
} else if (
|
||||||
|
baseUrlLower.includes('localhost') ||
|
||||||
|
baseUrlLower.includes('127.0.0.1') ||
|
||||||
|
baseUrlLower.includes('internal')
|
||||||
|
) {
|
||||||
|
inferredProvider = 'custom-openai';
|
||||||
|
} else {
|
||||||
|
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
inferredProvider = cachedApiKey ? 'openai' : 'custom-openai';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseUrl = cachedBaseUrl || '';
|
||||||
|
if (!baseUrl) {
|
||||||
|
if (inferredProvider === 'openai') {
|
||||||
|
baseUrl = PROVIDER_DEFAULT_BASE_URL.openai;
|
||||||
|
} else if (inferredProvider === 'deepinfra') {
|
||||||
|
baseUrl = PROVIDER_DEFAULT_BASE_URL.deepinfra;
|
||||||
|
} else {
|
||||||
|
baseUrl = PROVIDER_DEFAULT_BASE_URL['custom-openai'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { provider: inferredProvider, baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
||||||
|
const { provider, baseUrl } = inferProviderAndBaseUrl(raw);
|
||||||
|
|
||||||
|
let savedVoices: SavedVoices = {};
|
||||||
|
if (raw.savedVoices) {
|
||||||
|
try {
|
||||||
|
savedVoices = JSON.parse(raw.savedVoices) as SavedVoices;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing savedVoices during migration:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let documentListState: DocumentListState = APP_CONFIG_DEFAULTS.documentListState;
|
||||||
|
if (raw.documentListState) {
|
||||||
|
try {
|
||||||
|
documentListState = JSON.parse(raw.documentListState) as DocumentListState;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing documentListState during migration:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: AppConfigRow = {
|
||||||
|
id: 'singleton',
|
||||||
|
...APP_CONFIG_DEFAULTS,
|
||||||
|
apiKey: raw.apiKey ?? APP_CONFIG_DEFAULTS.apiKey,
|
||||||
|
baseUrl,
|
||||||
|
viewType: (raw.viewType as ViewType) || APP_CONFIG_DEFAULTS.viewType,
|
||||||
|
voiceSpeed: raw.voiceSpeed ? parseFloat(raw.voiceSpeed) : APP_CONFIG_DEFAULTS.voiceSpeed,
|
||||||
|
audioPlayerSpeed: raw.audioPlayerSpeed ? parseFloat(raw.audioPlayerSpeed) : APP_CONFIG_DEFAULTS.audioPlayerSpeed,
|
||||||
|
voice: '',
|
||||||
|
skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank,
|
||||||
|
epubTheme: raw.epubTheme === 'true',
|
||||||
|
smartSentenceSplitting:
|
||||||
|
raw.smartSentenceSplitting === 'false' ? false : APP_CONFIG_DEFAULTS.smartSentenceSplitting,
|
||||||
|
headerMargin: raw.headerMargin ? parseFloat(raw.headerMargin) : APP_CONFIG_DEFAULTS.headerMargin,
|
||||||
|
footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin,
|
||||||
|
leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin,
|
||||||
|
rightMargin: raw.rightMargin ? parseFloat(raw.rightMargin) : APP_CONFIG_DEFAULTS.rightMargin,
|
||||||
|
ttsProvider: provider || APP_CONFIG_DEFAULTS.ttsProvider,
|
||||||
|
ttsModel:
|
||||||
|
raw.ttsModel ||
|
||||||
|
(provider === 'openai'
|
||||||
|
? 'tts-1'
|
||||||
|
: provider === 'deepinfra'
|
||||||
|
? 'hexgrad/Kokoro-82M'
|
||||||
|
: APP_CONFIG_DEFAULTS.ttsModel),
|
||||||
|
ttsInstructions: raw.ttsInstructions ?? APP_CONFIG_DEFAULTS.ttsInstructions,
|
||||||
|
savedVoices,
|
||||||
|
pdfHighlightEnabled:
|
||||||
|
raw.pdfHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfHighlightEnabled,
|
||||||
|
firstVisit: raw.firstVisit === 'true',
|
||||||
|
documentListState,
|
||||||
|
};
|
||||||
|
|
||||||
|
const voiceKey = `${config.ttsProvider}:${config.ttsModel}`;
|
||||||
|
config.voice = config.savedVoices[voiceKey] || '';
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version 5: introduce app-config and last-locations tables, migrate scattered config keys,
|
||||||
|
// and drop the legacy config table in a single upgrade step.
|
||||||
|
db.version(DB_VERSION).stores({
|
||||||
|
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId',
|
||||||
|
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId',
|
||||||
|
[HTML_TABLE]: 'id, type, name, lastModified, size, folderId',
|
||||||
|
[APP_CONFIG_TABLE]: 'id',
|
||||||
|
[LAST_LOCATION_TABLE]: 'docId',
|
||||||
|
// `null` here means: drop the old 'config' table after upgrade runs,
|
||||||
|
// but Dexie still lets us read it inside the upgrade transaction.
|
||||||
|
[CONFIG_TABLE]: null,
|
||||||
|
}).upgrade(async (trans) => {
|
||||||
|
const appConfig = await trans.table<AppConfigRow, string>(APP_CONFIG_TABLE).get('singleton');
|
||||||
|
if (appConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configRows = await trans.table<ConfigRow, string>(CONFIG_TABLE).toArray();
|
||||||
|
const raw: RawConfigMap = {};
|
||||||
|
|
||||||
|
for (const row of configRows) {
|
||||||
|
raw[row.key] = row.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = buildAppConfigFromRaw(raw);
|
||||||
|
await trans.table<AppConfigRow, string>(APP_CONFIG_TABLE).put(built);
|
||||||
|
|
||||||
|
// Migrate any legacy lastLocation_* keys into the dedicated last-locations table.
|
||||||
|
const locationTable = trans.table<LastLocationRow, string>(LAST_LOCATION_TABLE);
|
||||||
|
for (const row of configRows) {
|
||||||
|
if (row.key.startsWith('lastLocation_')) {
|
||||||
|
const docId = row.key.substring('lastLocation_'.length);
|
||||||
|
await locationTable.put({ docId, location: row.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let dbOpenPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export async function initDB(): Promise<void> {
|
||||||
|
if (dbOpenPromise) {
|
||||||
|
return dbOpenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbOpenPromise = (async () => {
|
||||||
|
try {
|
||||||
|
console.log('Opening Dexie database...');
|
||||||
|
await db.open();
|
||||||
|
console.log('Dexie database opened successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dexie initialization error:', error);
|
||||||
|
dbOpenPromise = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return dbOpenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withDB<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
await initDB();
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF document helpers
|
||||||
|
|
||||||
|
export async function addPdfDocument(document: PDFDocument): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
console.log('Adding PDF document via Dexie:', document.name);
|
||||||
|
await db[PDF_TABLE].put(document);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPdfDocument(id: string): Promise<PDFDocument | undefined> {
|
||||||
|
return withDB(async () => {
|
||||||
|
console.log('Fetching PDF document via Dexie:', id);
|
||||||
|
return db[PDF_TABLE].get(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllPdfDocuments(): Promise<PDFDocument[]> {
|
||||||
|
return withDB(async () => {
|
||||||
|
console.log('Fetching all PDF documents via Dexie');
|
||||||
|
return db[PDF_TABLE].toArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePdfDocument(id: string): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
console.log('Removing PDF document via Dexie:', id);
|
||||||
|
await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], async () => {
|
||||||
|
await db[PDF_TABLE].delete(id);
|
||||||
|
await db[LAST_LOCATION_TABLE].delete(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPdfDocuments(): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
console.log('Clearing all PDF documents via Dexie');
|
||||||
|
await db[PDF_TABLE].clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// EPUB document helpers
|
||||||
|
|
||||||
|
export async function addEpubDocument(document: EPUBDocument): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
if (document.data.byteLength === 0) {
|
||||||
|
throw new Error('Cannot store empty ArrayBuffer');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Adding EPUB document via Dexie:', {
|
||||||
|
name: document.name,
|
||||||
|
size: document.size,
|
||||||
|
actualSize: document.data.byteLength,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db[EPUB_TABLE].put(document);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEpubDocument(id: string): Promise<EPUBDocument | undefined> {
|
||||||
|
return withDB(async () => {
|
||||||
|
console.log('Fetching EPUB document via Dexie:', id);
|
||||||
|
return db[EPUB_TABLE].get(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllEpubDocuments(): Promise<EPUBDocument[]> {
|
||||||
|
return withDB(async () => {
|
||||||
|
console.log('Fetching all EPUB documents via Dexie');
|
||||||
|
return db[EPUB_TABLE].toArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEpubDocument(id: string): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
console.log('Removing EPUB document via Dexie:', id);
|
||||||
|
await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], async () => {
|
||||||
|
await db[EPUB_TABLE].delete(id);
|
||||||
|
await db[LAST_LOCATION_TABLE].delete(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearEpubDocuments(): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
console.log('Clearing all EPUB documents via Dexie');
|
||||||
|
await db[EPUB_TABLE].clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML / text document helpers
|
||||||
|
|
||||||
|
export async function addHtmlDocument(document: HTMLDocument): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
console.log('Adding HTML document via Dexie:', document.name);
|
||||||
|
await db[HTML_TABLE].put(document);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHtmlDocument(id: string): Promise<HTMLDocument | undefined> {
|
||||||
|
return withDB(async () => {
|
||||||
|
console.log('Fetching HTML document via Dexie:', id);
|
||||||
|
return db[HTML_TABLE].get(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllHtmlDocuments(): Promise<HTMLDocument[]> {
|
||||||
|
return withDB(async () => {
|
||||||
|
console.log('Fetching all HTML documents via Dexie');
|
||||||
|
return db[HTML_TABLE].toArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeHtmlDocument(id: string): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
console.log('Removing HTML document via Dexie:', id);
|
||||||
|
await db[HTML_TABLE].delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearHtmlDocuments(): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
console.log('Clearing all HTML documents via Dexie');
|
||||||
|
await db[HTML_TABLE].clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAppConfig(): Promise<AppConfigRow | null> {
|
||||||
|
return withDB(async () => {
|
||||||
|
const row = await db[APP_CONFIG_TABLE].get('singleton');
|
||||||
|
return row ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAppConfig(partial: Partial<AppConfigRow>): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
const table = db[APP_CONFIG_TABLE];
|
||||||
|
const existing = await table.get('singleton');
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await table.put({
|
||||||
|
id: 'singleton',
|
||||||
|
...APP_CONFIG_DEFAULTS,
|
||||||
|
...partial,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await table.update('singleton', partial);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document list state helpers
|
||||||
|
|
||||||
|
export async function saveDocumentListState(state: DocumentListState): Promise<void> {
|
||||||
|
await updateAppConfig({ documentListState: state });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocumentListState(): Promise<DocumentListState | null> {
|
||||||
|
const config = await getAppConfig();
|
||||||
|
if (!config || !config.documentListState) return null;
|
||||||
|
return config.documentListState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-location helpers (used by TTS and readers)
|
||||||
|
|
||||||
|
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
|
||||||
|
return withDB(async () => {
|
||||||
|
const row = await db[LAST_LOCATION_TABLE].get(docId);
|
||||||
|
return row ? row.location : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLastDocumentLocation(docId: string, location: string): Promise<void> {
|
||||||
|
await withDB(async () => {
|
||||||
|
await db[LAST_LOCATION_TABLE].put({ docId, location });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-visit helpers (used for onboarding/Settings modal)
|
||||||
|
|
||||||
|
export async function getFirstVisit(): Promise<boolean> {
|
||||||
|
const config = await getAppConfig();
|
||||||
|
return config?.firstVisit ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFirstVisit(value: boolean): Promise<void> {
|
||||||
|
await updateAppConfig({ firstVisit: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync helpers (server round-trip)
|
||||||
|
|
||||||
|
export async function syncDocumentsToServer(
|
||||||
|
onProgress?: (progress: number, status?: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ lastSync: number }> {
|
||||||
|
const pdfDocs = await getAllPdfDocuments();
|
||||||
|
const epubDocs = await getAllEpubDocuments();
|
||||||
|
const htmlDocs = await getAllHtmlDocuments();
|
||||||
|
|
||||||
|
const documents: SyncedDocument[] = [];
|
||||||
|
const totalDocs = pdfDocs.length + epubDocs.length + htmlDocs.length;
|
||||||
|
let processedDocs = 0;
|
||||||
|
|
||||||
|
for (const doc of pdfDocs) {
|
||||||
|
documents.push({
|
||||||
|
...doc,
|
||||||
|
type: 'pdf',
|
||||||
|
data: Array.from(new Uint8Array(doc.data)),
|
||||||
|
});
|
||||||
|
processedDocs++;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const doc of epubDocs) {
|
||||||
|
documents.push({
|
||||||
|
...doc,
|
||||||
|
type: 'epub',
|
||||||
|
data: Array.from(new Uint8Array(doc.data)),
|
||||||
|
});
|
||||||
|
processedDocs++;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
for (const doc of htmlDocs) {
|
||||||
|
const encoded = encoder.encode(doc.data);
|
||||||
|
documents.push({
|
||||||
|
...doc,
|
||||||
|
type: 'html',
|
||||||
|
data: Array.from(encoded),
|
||||||
|
});
|
||||||
|
processedDocs++;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(50, 'Uploading to server...');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/documents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ documents }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to sync documents to server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(100, 'Upload complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lastSync: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDocumentsFromServer(
|
||||||
|
onProgress?: (progress: number, status?: string) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ lastSync: number }> {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(10, 'Starting download...');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/documents', { signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch documents from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(30, 'Download complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { documents } = (await response.json()) as { documents: SyncedDocument[] };
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(40, 'Parsing documents...');
|
||||||
|
}
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
for (let i = 0; i < documents.length; i++) {
|
||||||
|
const doc = documents[i];
|
||||||
|
|
||||||
|
if (doc.type === 'pdf') {
|
||||||
|
const uint8Array = new Uint8Array(doc.data);
|
||||||
|
const documentData: PDFDocument = {
|
||||||
|
id: doc.id,
|
||||||
|
type: 'pdf',
|
||||||
|
name: doc.name,
|
||||||
|
size: doc.size,
|
||||||
|
lastModified: doc.lastModified,
|
||||||
|
data: uint8Array.buffer,
|
||||||
|
};
|
||||||
|
await addPdfDocument(documentData);
|
||||||
|
} else if (doc.type === 'epub') {
|
||||||
|
const uint8Array = new Uint8Array(doc.data);
|
||||||
|
const documentData: EPUBDocument = {
|
||||||
|
id: doc.id,
|
||||||
|
type: 'epub',
|
||||||
|
name: doc.name,
|
||||||
|
size: doc.size,
|
||||||
|
lastModified: doc.lastModified,
|
||||||
|
data: uint8Array.buffer,
|
||||||
|
};
|
||||||
|
await addEpubDocument(documentData);
|
||||||
|
} else if (doc.type === 'html') {
|
||||||
|
const uint8Array = new Uint8Array(doc.data);
|
||||||
|
const decoded = textDecoder.decode(uint8Array);
|
||||||
|
const documentData: HTMLDocument = {
|
||||||
|
id: doc.id,
|
||||||
|
type: 'html',
|
||||||
|
name: doc.name,
|
||||||
|
size: doc.size,
|
||||||
|
lastModified: doc.lastModified,
|
||||||
|
data: decoded,
|
||||||
|
};
|
||||||
|
await addHtmlDocument(documentData);
|
||||||
|
} else {
|
||||||
|
console.warn(`Unknown document type: ${doc.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(100, 'Load complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lastSync: Date.now() };
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import nlp from 'compromise';
|
import nlp from 'compromise';
|
||||||
|
|
||||||
const MAX_BLOCK_LENGTH = 300;
|
const MAX_BLOCK_LENGTH = 450;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preprocesses text for audio generation by cleaning up various text artifacts
|
* Preprocesses text for audio generation by cleaning up various text artifacts
|
||||||
|
|
@ -42,11 +42,14 @@ export const splitIntoSentences = (text: string): string[] => {
|
||||||
const doc = nlp(cleanedText);
|
const doc = nlp(cleanedText);
|
||||||
const rawSentences = doc.sentences().out('array') as string[];
|
const rawSentences = doc.sentences().out('array') as string[];
|
||||||
|
|
||||||
|
// Merge multi-sentence dialogue enclosed in quotes into single items
|
||||||
|
const mergedSentences = mergeQuotedDialogue(rawSentences);
|
||||||
|
|
||||||
let currentBlock = '';
|
let currentBlock = '';
|
||||||
|
|
||||||
for (const sentence of rawSentences) {
|
for (const sentence of mergedSentences) {
|
||||||
const trimmedSentence = sentence.trim();
|
const trimmedSentence = sentence.trim();
|
||||||
|
|
||||||
if (currentBlock && (currentBlock.length + trimmedSentence.length + 1) > MAX_BLOCK_LENGTH) {
|
if (currentBlock && (currentBlock.length + trimmedSentence.length + 1) > MAX_BLOCK_LENGTH) {
|
||||||
blocks.push(currentBlock.trim());
|
blocks.push(currentBlock.trim());
|
||||||
currentBlock = trimmedSentence;
|
currentBlock = trimmedSentence;
|
||||||
|
|
@ -76,13 +79,8 @@ export const processTextToSentences = (text: string): string[] => {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.length <= MAX_BLOCK_LENGTH) {
|
// Always use the full splitting logic so we consistently respect
|
||||||
// Single sentence preprocessing
|
// sentence boundaries and quoted dialogue, even for shorter texts.
|
||||||
const cleanedText = preprocessSentenceForAudio(text);
|
|
||||||
return [cleanedText];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full text splitting into sentences
|
|
||||||
return splitIntoSentences(text);
|
return splitIntoSentences(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -150,4 +148,72 @@ export const processTextWithMapping = (text: string): {
|
||||||
rawSentences,
|
rawSentences,
|
||||||
sentenceMapping
|
sentenceMapping
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
// Helper functions to merge quoted dialogue across sentences
|
||||||
|
const countDoubleQuotes = (s: string): number => {
|
||||||
|
const matches = s.match(/["“”]/g);
|
||||||
|
return matches ? matches.length : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace the old curly single-quote counter and standalone-straight counter with a unified, context-aware counter
|
||||||
|
const countNonApostropheSingleQuotes = (s: string): number => {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (ch === "'" || ch === '‘' || ch === '’') {
|
||||||
|
const prev = i > 0 ? s[i - 1] : '';
|
||||||
|
const next = i + 1 < s.length ? s[i + 1] : '';
|
||||||
|
const isPrevAlphaNum = /[A-Za-z0-9]/.test(prev);
|
||||||
|
const isNextAlphaNum = /[A-Za-z0-9]/.test(next);
|
||||||
|
// Treat as a real quote mark only when it's not clearly an apostrophe
|
||||||
|
// between two alphanumeric characters (e.g., don't, WizardLM’s).
|
||||||
|
if (!(isPrevAlphaNum && isNextAlphaNum)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeQuotedDialogue = (rawSentences: string[]): string[] => {
|
||||||
|
const result: string[] = [];
|
||||||
|
let buffer = '';
|
||||||
|
let insideDouble = false;
|
||||||
|
let insideSingle = false;
|
||||||
|
|
||||||
|
for (const s of rawSentences) {
|
||||||
|
const t = s.trim();
|
||||||
|
const dblCount = countDoubleQuotes(t);
|
||||||
|
// Use the new context-aware single-quote counter so curly apostrophes
|
||||||
|
// inside words don't incorrectly toggle quote state and merge large
|
||||||
|
// regions of plain prose into one block.
|
||||||
|
const singleCount = countNonApostropheSingleQuotes(t);
|
||||||
|
|
||||||
|
if (insideDouble || insideSingle) {
|
||||||
|
buffer = buffer ? `${buffer} ${t}` : t;
|
||||||
|
} else {
|
||||||
|
// Start buffering if this sentence opens an unclosed quote
|
||||||
|
if ((dblCount % 2 === 1) || (singleCount % 2 === 1)) {
|
||||||
|
buffer = t;
|
||||||
|
} else {
|
||||||
|
result.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle quote states after processing this sentence
|
||||||
|
if (dblCount % 2 === 1) insideDouble = !insideDouble;
|
||||||
|
if (singleCount % 2 === 1) insideSingle = !insideSingle;
|
||||||
|
|
||||||
|
// If all open quotes are closed, flush buffer
|
||||||
|
if (!(insideDouble || insideSingle) && buffer) {
|
||||||
|
result.push(buffer);
|
||||||
|
buffer = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer) {
|
||||||
|
result.push(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
695
src/lib/pdf.ts
Normal file
695
src/lib/pdf.ts
Normal file
|
|
@ -0,0 +1,695 @@
|
||||||
|
import { pdfjs } from 'react-pdf';
|
||||||
|
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist';
|
||||||
|
import "core-js/proposals/promise-with-resolvers";
|
||||||
|
import { processTextToSentences } from '@/lib/nlp';
|
||||||
|
import { CmpStr } from 'cmpstr';
|
||||||
|
|
||||||
|
const cmp = CmpStr.create().setMetric( 'dice' ).setFlags( 'itw' );
|
||||||
|
|
||||||
|
// Worker coordination for offloading highlight token matching
|
||||||
|
interface HighlightTokenMatchRequest {
|
||||||
|
id: string;
|
||||||
|
type: 'tokenMatch';
|
||||||
|
pattern: string;
|
||||||
|
tokenTexts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HighlightTokenMatchResponse {
|
||||||
|
id: string;
|
||||||
|
type: 'tokenMatchResult';
|
||||||
|
bestStart: number;
|
||||||
|
bestEnd: number;
|
||||||
|
rating: number;
|
||||||
|
lengthDiff: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let highlightWorker: Worker | null = null;
|
||||||
|
|
||||||
|
function getHighlightWorker(): Worker | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if (highlightWorker) return highlightWorker;
|
||||||
|
|
||||||
|
try {
|
||||||
|
highlightWorker = new Worker(
|
||||||
|
new URL('pdfHighlightWorker.ts', import.meta.url),
|
||||||
|
{ type: 'module' }
|
||||||
|
);
|
||||||
|
return highlightWorker;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize PDF highlight worker:', e);
|
||||||
|
highlightWorker = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHighlightTokenMatch(
|
||||||
|
pattern: string,
|
||||||
|
tokenTexts: string[]
|
||||||
|
): Promise<HighlightTokenMatchResponse | null> {
|
||||||
|
const worker = getHighlightWorker();
|
||||||
|
if (!worker) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
const data = event.data as HighlightTokenMatchResponse;
|
||||||
|
if (!data || data.id !== id || data.type !== 'tokenMatchResult') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
worker.removeEventListener('message', handleMessage as EventListener);
|
||||||
|
resolve(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.addEventListener('message', handleMessage as EventListener);
|
||||||
|
|
||||||
|
const message: HighlightTokenMatchRequest = {
|
||||||
|
id,
|
||||||
|
type: 'tokenMatch',
|
||||||
|
pattern,
|
||||||
|
tokenTexts,
|
||||||
|
};
|
||||||
|
worker.postMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to detect if we need to use legacy build
|
||||||
|
function shouldUseLegacyBuild() {
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const ua = window.navigator.userAgent;
|
||||||
|
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
|
||||||
|
|
||||||
|
console.log(isSafari ? 'Running on Safari' : 'Not running on Safari');
|
||||||
|
if (!isSafari) return false;
|
||||||
|
|
||||||
|
// Extract Safari version - matches "Version/18" format
|
||||||
|
const match = ua.match(/Version\/(\d+)/i);
|
||||||
|
console.log('Safari version:', match);
|
||||||
|
if (!match || !match[1]) return true; // If we can't determine version, use legacy to be safe
|
||||||
|
|
||||||
|
const version = parseInt(match[1]);
|
||||||
|
return version < 18; // Use legacy build for Safari versions equal or below 18
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error detecting Safari version:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to initialize PDF worker
|
||||||
|
function initPDFWorker() {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const useLegacy = shouldUseLegacyBuild();
|
||||||
|
// Use local worker file instead of unpkg
|
||||||
|
const workerSrc = useLegacy
|
||||||
|
? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href
|
||||||
|
: new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href;
|
||||||
|
console.log('Setting PDF worker to:', workerSrc);
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
||||||
|
pdfjs.GlobalWorkerOptions.workerPort = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error setting PDF worker:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the worker
|
||||||
|
initPDFWorker();
|
||||||
|
|
||||||
|
// Patch TextLayer.render to treat cancelled renders as non-errors
|
||||||
|
try {
|
||||||
|
const textLayerProto = TextLayer?.prototype;
|
||||||
|
const originalRender = textLayerProto?.render;
|
||||||
|
if (typeof originalRender === 'function') {
|
||||||
|
textLayerProto.render = async function patchedRender(...args) {
|
||||||
|
const task = originalRender.apply(this, args);
|
||||||
|
if (!task || typeof task.then !== 'function') return task;
|
||||||
|
return task.catch((error) => {
|
||||||
|
if (error && (error.name === 'AbortException' || error.name === 'RenderingCancelledException')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error patching TextLayer.render:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextMatch {
|
||||||
|
elements: HTMLElement[];
|
||||||
|
rating: number;
|
||||||
|
text: string;
|
||||||
|
lengthDiff: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text Processing functions
|
||||||
|
export async function extractTextFromPDF(
|
||||||
|
pdf: PDFDocumentProxy,
|
||||||
|
pageNumber: number,
|
||||||
|
margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Log pdf worker version
|
||||||
|
//console.log('PDF worker version:', pdfjs.GlobalWorkerOptions.workerSrc);
|
||||||
|
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
const textContent = await page.getTextContent();
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale: 1.0 });
|
||||||
|
const pageHeight = viewport.height;
|
||||||
|
const pageWidth = viewport.width;
|
||||||
|
|
||||||
|
const textItems = textContent.items.filter((item): item is TextItem => {
|
||||||
|
if (!('str' in item && 'transform' in item)) return false;
|
||||||
|
|
||||||
|
const [scaleX, skewX, skewY, scaleY, x, y] = item.transform;
|
||||||
|
|
||||||
|
// Basic text filtering
|
||||||
|
if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false;
|
||||||
|
if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false;
|
||||||
|
if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false;
|
||||||
|
|
||||||
|
// Calculate margins in PDF coordinate space (y=0 is at bottom)
|
||||||
|
const headerY = pageHeight * (1 - margins.header); // Convert from top margin to bottom-based Y
|
||||||
|
const footerY = pageHeight * margins.footer; // Footer Y stays as is since it's already bottom-based
|
||||||
|
const leftX = pageWidth * margins.left;
|
||||||
|
const rightX = pageWidth * (1 - margins.right);
|
||||||
|
|
||||||
|
// Check margins - remember y=0 is at bottom of page in PDF coordinates
|
||||||
|
if (y > headerY || y < footerY) { // Y greater than headerY means it's in header area, less than footerY means footer area
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check horizontal margins
|
||||||
|
if (x < leftX || x > rightX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check for coordinates
|
||||||
|
if (x < 0 || x > pageWidth) return false;
|
||||||
|
|
||||||
|
return item.str.trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
//console.log('Filtered text items:', textItems);
|
||||||
|
|
||||||
|
const tolerance = 2;
|
||||||
|
const lines: TextItem[][] = [];
|
||||||
|
let currentLine: TextItem[] = [];
|
||||||
|
let currentY: number | null = null;
|
||||||
|
|
||||||
|
textItems.forEach((item) => {
|
||||||
|
const y = item.transform[5];
|
||||||
|
if (currentY === null) {
|
||||||
|
currentY = y;
|
||||||
|
currentLine.push(item);
|
||||||
|
} else if (Math.abs(y - currentY) < tolerance) {
|
||||||
|
currentLine.push(item);
|
||||||
|
} else {
|
||||||
|
lines.push(currentLine);
|
||||||
|
currentLine = [item];
|
||||||
|
currentY = y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lines.push(currentLine);
|
||||||
|
|
||||||
|
let pageText = '';
|
||||||
|
for (const line of lines) {
|
||||||
|
line.sort((a, b) => a.transform[4] - b.transform[4]);
|
||||||
|
let lineText = '';
|
||||||
|
let prevItem: TextItem | null = null;
|
||||||
|
|
||||||
|
for (const item of line) {
|
||||||
|
if (!prevItem) {
|
||||||
|
lineText = item.str;
|
||||||
|
} else {
|
||||||
|
const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0);
|
||||||
|
const currentStartX = item.transform[4];
|
||||||
|
const space = currentStartX - prevEndX;
|
||||||
|
|
||||||
|
// Get average character width as fallback
|
||||||
|
const avgCharWidth = (item.width ?? 0) / Math.max(1, item.str.length);
|
||||||
|
|
||||||
|
// Multiple conditions for space detection
|
||||||
|
const needsSpace =
|
||||||
|
// Primary check: significant gap between items
|
||||||
|
space > Math.max(avgCharWidth * 0.3, 2) ||
|
||||||
|
// Secondary check: natural word boundary
|
||||||
|
(!/^\W/.test(item.str) && !/\W$/.test(prevItem.str)) ||
|
||||||
|
// Tertiary check: items are far enough apart relative to their size
|
||||||
|
(space > ((prevItem.width ?? 0) * 0.25));
|
||||||
|
|
||||||
|
if (needsSpace) {
|
||||||
|
lineText += ' ' + item.str;
|
||||||
|
} else {
|
||||||
|
lineText += item.str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevItem = item;
|
||||||
|
}
|
||||||
|
pageText += lineText + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageText.replace(/\s+/g, ' ').trim();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting text from PDF:', error);
|
||||||
|
throw new Error('Failed to extract text from PDF');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlighting functions
|
||||||
|
export function clearHighlights() {
|
||||||
|
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
|
||||||
|
textNodes.forEach((node) => {
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
element.style.backgroundColor = '';
|
||||||
|
element.style.opacity = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlays = document.querySelectorAll('.pdf-text-highlight-overlay');
|
||||||
|
overlays.forEach((node) => {
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
if (element.parentElement) {
|
||||||
|
element.parentElement.removeChild(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBestTextMatch(
|
||||||
|
elements: Array<{ element: HTMLElement; text: string }>,
|
||||||
|
targetText: string,
|
||||||
|
maxCombinedLength: number
|
||||||
|
): TextMatch {
|
||||||
|
let bestMatch = {
|
||||||
|
elements: [] as HTMLElement[],
|
||||||
|
rating: 0,
|
||||||
|
text: '',
|
||||||
|
lengthDiff: Infinity,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPAN_SEARCH_LIMIT = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
let combinedText = '';
|
||||||
|
const currentElements = [];
|
||||||
|
for (let j = i; j < Math.min(i + SPAN_SEARCH_LIMIT, elements.length); j++) {
|
||||||
|
const node = elements[j];
|
||||||
|
const newText = combinedText ? `${combinedText} ${node.text}` : node.text;
|
||||||
|
if (newText.length > maxCombinedLength) break;
|
||||||
|
|
||||||
|
combinedText = newText;
|
||||||
|
currentElements.push(node.element);
|
||||||
|
|
||||||
|
const similarity = cmp.compare(combinedText, targetText);
|
||||||
|
const lengthDiff = Math.abs(combinedText.length - targetText.length);
|
||||||
|
const lengthPenalty = lengthDiff / targetText.length;
|
||||||
|
const adjustedRating = similarity * (1 - lengthPenalty * 0.5);
|
||||||
|
|
||||||
|
if (adjustedRating > bestMatch.rating) {
|
||||||
|
bestMatch = {
|
||||||
|
elements: [...currentElements],
|
||||||
|
rating: adjustedRating,
|
||||||
|
text: combinedText,
|
||||||
|
lengthDiff,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightPattern(
|
||||||
|
text: string,
|
||||||
|
pattern: string,
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>
|
||||||
|
) {
|
||||||
|
clearHighlights();
|
||||||
|
|
||||||
|
if (!pattern?.trim()) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!cleanPattern) return;
|
||||||
|
|
||||||
|
const spanNodes = Array.from(
|
||||||
|
container.querySelectorAll('.react-pdf__Page__textContent span')
|
||||||
|
) as HTMLElement[];
|
||||||
|
|
||||||
|
if (!spanNodes.length) return;
|
||||||
|
|
||||||
|
type Token = {
|
||||||
|
spanIndex: number;
|
||||||
|
textNode: Text;
|
||||||
|
text: string;
|
||||||
|
startOffset: number;
|
||||||
|
endOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
|
||||||
|
spanNodes.forEach((span, spanIndex) => {
|
||||||
|
const node = span.firstChild;
|
||||||
|
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
||||||
|
|
||||||
|
const textNode = node as Text;
|
||||||
|
const textContent = textNode.textContent || '';
|
||||||
|
const wordRegex = /\S+/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = wordRegex.exec(textContent)) !== null) {
|
||||||
|
const word = match[0];
|
||||||
|
tokens.push({
|
||||||
|
spanIndex,
|
||||||
|
textNode,
|
||||||
|
text: word,
|
||||||
|
startOffset: match.index,
|
||||||
|
endOffset: match.index + word.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokens.length) return;
|
||||||
|
|
||||||
|
const patternLen = cleanPattern.length;
|
||||||
|
|
||||||
|
// Core application of highlight logic once we know the best token window (if any)
|
||||||
|
const applyHighlightFromTokens = (
|
||||||
|
tokenMatch:
|
||||||
|
| {
|
||||||
|
bestStart: number;
|
||||||
|
bestEnd: number;
|
||||||
|
rating: number;
|
||||||
|
lengthDiff: number;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
) => {
|
||||||
|
const highlightRanges: Array<{
|
||||||
|
textNode: Text;
|
||||||
|
startOffset: number;
|
||||||
|
endOffset: number;
|
||||||
|
span: HTMLElement;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let bestStart = -1;
|
||||||
|
let bestEnd = -1;
|
||||||
|
let bestRating = 0;
|
||||||
|
let bestLengthDiff = Infinity;
|
||||||
|
|
||||||
|
if (tokenMatch) {
|
||||||
|
bestStart = tokenMatch.bestStart;
|
||||||
|
bestEnd = tokenMatch.bestEnd;
|
||||||
|
bestRating = tokenMatch.rating;
|
||||||
|
bestLengthDiff = tokenMatch.lengthDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTokenMatch = bestStart !== -1;
|
||||||
|
const similarityThreshold =
|
||||||
|
bestLengthDiff < patternLen * 0.3 ? 0.3 : 0.5;
|
||||||
|
|
||||||
|
if (hasTokenMatch && bestRating >= similarityThreshold) {
|
||||||
|
const rangesBySpan = new Map<
|
||||||
|
number,
|
||||||
|
{ startOffset: number; endOffset: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (let i = bestStart; i <= bestEnd; i++) {
|
||||||
|
const token = tokens[i];
|
||||||
|
const existing = rangesBySpan.get(token.spanIndex);
|
||||||
|
if (!existing) {
|
||||||
|
rangesBySpan.set(token.spanIndex, {
|
||||||
|
startOffset: token.startOffset,
|
||||||
|
endOffset: token.endOffset,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
existing.startOffset = Math.min(
|
||||||
|
existing.startOffset,
|
||||||
|
token.startOffset
|
||||||
|
);
|
||||||
|
existing.endOffset = Math.max(
|
||||||
|
existing.endOffset,
|
||||||
|
token.endOffset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rangesBySpan.forEach(({ startOffset, endOffset }, spanIndex) => {
|
||||||
|
const span = spanNodes[spanIndex];
|
||||||
|
const node = span.firstChild;
|
||||||
|
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
||||||
|
|
||||||
|
highlightRanges.push({
|
||||||
|
textNode: node as Text,
|
||||||
|
startOffset,
|
||||||
|
endOffset,
|
||||||
|
span,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if token-level matching failed, use span-based fuzzy matching
|
||||||
|
if (!highlightRanges.length) {
|
||||||
|
const spanEntries = spanNodes
|
||||||
|
.map((node) => ({
|
||||||
|
element: node as HTMLElement,
|
||||||
|
text: (node.textContent || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.text.length > 0);
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const visibleTop = container.scrollTop;
|
||||||
|
const visibleBottom = visibleTop + containerRect.height;
|
||||||
|
const bufferSize = containerRect.height;
|
||||||
|
|
||||||
|
const visibleNodes = spanEntries.filter(({ element }) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const elementTop =
|
||||||
|
rect.top - containerRect.top + container.scrollTop;
|
||||||
|
return (
|
||||||
|
elementTop >= visibleTop - bufferSize &&
|
||||||
|
elementTop <= visibleBottom + bufferSize
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let bestMatch = findBestTextMatch(
|
||||||
|
visibleNodes,
|
||||||
|
cleanPattern,
|
||||||
|
cleanPattern.length * 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bestMatch.rating < 0.3) {
|
||||||
|
bestMatch = findBestTextMatch(
|
||||||
|
spanEntries,
|
||||||
|
cleanPattern,
|
||||||
|
cleanPattern.length * 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spanSimilarityThreshold =
|
||||||
|
bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5;
|
||||||
|
|
||||||
|
if (bestMatch.rating >= spanSimilarityThreshold) {
|
||||||
|
bestMatch.elements.forEach((element) => {
|
||||||
|
const node = element.firstChild;
|
||||||
|
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
||||||
|
const textNode = node as Text;
|
||||||
|
const content = textNode.textContent || '';
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
highlightRanges.push({
|
||||||
|
textNode,
|
||||||
|
startOffset: 0,
|
||||||
|
endOffset: content.length,
|
||||||
|
span: element,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!highlightRanges.length) return;
|
||||||
|
|
||||||
|
// Create overlay rectangles for each range, relative to its page text layer
|
||||||
|
const scrollIntoViewRects: DOMRect[] = [];
|
||||||
|
|
||||||
|
highlightRanges.forEach(({ textNode, startOffset, endOffset, span }) => {
|
||||||
|
try {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(textNode, startOffset);
|
||||||
|
range.setEnd(textNode, endOffset);
|
||||||
|
|
||||||
|
const pageLayer = span.closest(
|
||||||
|
'.react-pdf__Page__textContent'
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!pageLayer) return;
|
||||||
|
|
||||||
|
const pageRect = pageLayer.getBoundingClientRect();
|
||||||
|
const rects = Array.from(range.getClientRects());
|
||||||
|
|
||||||
|
rects.forEach((rect) => {
|
||||||
|
const highlight = document.createElement('div');
|
||||||
|
highlight.className = 'pdf-text-highlight-overlay';
|
||||||
|
highlight.style.position = 'absolute';
|
||||||
|
highlight.style.backgroundColor = 'grey';
|
||||||
|
highlight.style.opacity = '0.4';
|
||||||
|
highlight.style.pointerEvents = 'none';
|
||||||
|
highlight.style.left = `${rect.left - pageRect.left}px`;
|
||||||
|
highlight.style.top = `${rect.top - pageRect.top}px`;
|
||||||
|
highlight.style.width = `${rect.width}px`;
|
||||||
|
highlight.style.height = `${rect.height}px`;
|
||||||
|
pageLayer.appendChild(highlight);
|
||||||
|
|
||||||
|
scrollIntoViewRects.push(rect);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If range creation fails for any reason, skip this segment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scrollIntoViewRects.length) return;
|
||||||
|
|
||||||
|
// Scroll the first highlighted rect into view if needed
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const visibleTop = container.scrollTop;
|
||||||
|
const visibleBottom = visibleTop + containerRect.height;
|
||||||
|
|
||||||
|
const firstRect = scrollIntoViewRects[0];
|
||||||
|
const elementTop =
|
||||||
|
firstRect.top - containerRect.top + container.scrollTop;
|
||||||
|
|
||||||
|
if (elementTop < visibleTop || elementTop > visibleBottom) {
|
||||||
|
container.scrollTo({
|
||||||
|
top: elementTop - containerRect.height / 3,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenTexts = tokens.map((t) => t.text);
|
||||||
|
|
||||||
|
// Fire-and-forget async worker call; UI thread returns immediately
|
||||||
|
runHighlightTokenMatch(cleanPattern, tokenTexts)
|
||||||
|
.then((result) => {
|
||||||
|
if (!result || result.bestStart === -1) {
|
||||||
|
// No worker result or no good match; rely on span-level fallback
|
||||||
|
applyHighlightFromTokens(null);
|
||||||
|
} else {
|
||||||
|
applyHighlightFromTokens({
|
||||||
|
bestStart: result.bestStart,
|
||||||
|
bestEnd: result.bestEnd,
|
||||||
|
rating: result.rating,
|
||||||
|
lengthDiff: result.lengthDiff,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
'Error in PDF highlight worker, falling back to span-based matching:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
applyHighlightFromTokens(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text Click Handler
|
||||||
|
export function handleTextClick(
|
||||||
|
event: MouseEvent,
|
||||||
|
pdfText: string,
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
|
stopAndPlayFromIndex: (index: number) => void,
|
||||||
|
isProcessing: boolean,
|
||||||
|
enableHighlight = true
|
||||||
|
) {
|
||||||
|
if (isProcessing) return;
|
||||||
|
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.matches('.react-pdf__Page__textContent span')) return;
|
||||||
|
|
||||||
|
const parentElement = target.closest('.react-pdf__Page__textContent');
|
||||||
|
if (!parentElement) return;
|
||||||
|
|
||||||
|
const spans = Array.from(parentElement.querySelectorAll('span'));
|
||||||
|
const clickedIndex = spans.indexOf(target);
|
||||||
|
const contextWindow = 3;
|
||||||
|
const startIndex = Math.max(0, clickedIndex - contextWindow);
|
||||||
|
const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow);
|
||||||
|
const contextText = spans
|
||||||
|
.slice(startIndex, endIndex + 1)
|
||||||
|
.map((span) => span.textContent)
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!contextText?.trim()) return;
|
||||||
|
|
||||||
|
const cleanContext = contextText.trim().replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
// Fast path when highlight overlays are disabled:
|
||||||
|
// avoid expensive span-level fuzzy matching and just map
|
||||||
|
// the clicked context to a sentence using cheap string checks.
|
||||||
|
if (!enableHighlight) {
|
||||||
|
const sentences = processTextToSentences(pdfText);
|
||||||
|
const idx = sentences.findIndex((sentence) => {
|
||||||
|
const cleanSentence = sentence.trim().replace(/\s+/g, ' ');
|
||||||
|
return (
|
||||||
|
cleanSentence.includes(cleanContext) ||
|
||||||
|
cleanContext.includes(cleanSentence)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
stopAndPlayFromIndex(idx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({
|
||||||
|
element: node as HTMLElement,
|
||||||
|
text: (node.textContent || '').trim(),
|
||||||
|
})).filter((node) => node.text.length > 0);
|
||||||
|
|
||||||
|
const bestMatch = findBestTextMatch(allText, cleanContext, cleanContext.length * 2);
|
||||||
|
const similarityThreshold = bestMatch.lengthDiff < cleanContext.length * 0.3 ? 0.3 : 0.5;
|
||||||
|
|
||||||
|
if (bestMatch.rating >= similarityThreshold) {
|
||||||
|
const matchText = bestMatch.text;
|
||||||
|
// Use the same sentence processing logic as TTSContext for consistency
|
||||||
|
const sentences = processTextToSentences(pdfText);
|
||||||
|
console.log("sentences inside handleTextClick: %d", sentences.length)
|
||||||
|
let bestSentenceMatch = { sentence: '', rating: 0 };
|
||||||
|
|
||||||
|
for (const sentence of sentences) {
|
||||||
|
const rating = cmp.compare(matchText, sentence);
|
||||||
|
if (rating > bestSentenceMatch.rating) {
|
||||||
|
bestSentenceMatch = { sentence, rating };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestSentenceMatch.rating >= 0.5) {
|
||||||
|
const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence);
|
||||||
|
if (sentenceIndex !== -1) {
|
||||||
|
stopAndPlayFromIndex(sentenceIndex);
|
||||||
|
if (enableHighlight) {
|
||||||
|
highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce for PDF viewer
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
150
src/lib/pdfHighlightWorker.ts
Normal file
150
src/lib/pdfHighlightWorker.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { CmpStr } from 'cmpstr';
|
||||||
|
|
||||||
|
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
||||||
|
|
||||||
|
interface TokenMatchRequest {
|
||||||
|
id: string;
|
||||||
|
type: 'tokenMatch';
|
||||||
|
pattern: string;
|
||||||
|
tokenTexts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenMatchResponse {
|
||||||
|
id: string;
|
||||||
|
type: 'tokenMatchResult';
|
||||||
|
bestStart: number;
|
||||||
|
bestEnd: number;
|
||||||
|
rating: number;
|
||||||
|
lengthDiff: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Token Matching Worker
|
||||||
|
|
||||||
|
This worker receives a pattern string and an array of token texts,
|
||||||
|
and attempts to find the best matching contiguous sequence of tokens
|
||||||
|
that aligns with the pattern.
|
||||||
|
|
||||||
|
It uses the Dice coefficient string similarity metric to evaluate
|
||||||
|
how closely different token windows match the pattern, adjusting
|
||||||
|
for length differences and applying a prefix-alignment boost.
|
||||||
|
|
||||||
|
The worker responds with the start and end indices of the best matching
|
||||||
|
token window, along with its similarity rating and length difference.
|
||||||
|
*/
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<TokenMatchRequest>) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || data.type !== 'tokenMatch') return;
|
||||||
|
|
||||||
|
const { id, pattern, tokenTexts } = data;
|
||||||
|
|
||||||
|
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
||||||
|
const patternLen = cleanPattern.length;
|
||||||
|
|
||||||
|
const responseBase: TokenMatchResponse = {
|
||||||
|
id,
|
||||||
|
type: 'tokenMatchResult',
|
||||||
|
bestStart: -1,
|
||||||
|
bestEnd: -1,
|
||||||
|
rating: 0,
|
||||||
|
lengthDiff: Number.POSITIVE_INFINITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!patternLen || !tokenTexts.length) {
|
||||||
|
(self as unknown as DedicatedWorkerGlobalScope).postMessage(responseBase);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patternTokens = cleanPattern.split(' ').filter(Boolean);
|
||||||
|
const patternTokenCount = patternTokens.length || 1;
|
||||||
|
|
||||||
|
const minWindowTokens = Math.max(1, Math.floor(patternTokenCount * 0.6));
|
||||||
|
const maxWindowTokens = Math.max(
|
||||||
|
minWindowTokens,
|
||||||
|
Math.ceil(patternTokenCount * 1.4)
|
||||||
|
);
|
||||||
|
|
||||||
|
let bestStart = -1;
|
||||||
|
let bestEnd = -1;
|
||||||
|
let bestRating = 0;
|
||||||
|
let bestLengthDiff = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let start = 0; start < tokenTexts.length; start++) {
|
||||||
|
let combined = '';
|
||||||
|
|
||||||
|
for (
|
||||||
|
let offset = 0;
|
||||||
|
offset < maxWindowTokens && start + offset < tokenTexts.length;
|
||||||
|
offset++
|
||||||
|
) {
|
||||||
|
const token = tokenTexts[start + offset];
|
||||||
|
combined = combined ? `${combined} ${token}` : token;
|
||||||
|
|
||||||
|
const windowSize = offset + 1;
|
||||||
|
if (windowSize < minWindowTokens) continue;
|
||||||
|
if (combined.length > patternLen * 2) break;
|
||||||
|
|
||||||
|
const similarity = cmp.compare(combined, cleanPattern);
|
||||||
|
const lengthDiff = Math.abs(combined.length - patternLen);
|
||||||
|
const lengthPenalty = lengthDiff / patternLen;
|
||||||
|
const adjustedRating = similarity * (1 - lengthPenalty * 0.3);
|
||||||
|
|
||||||
|
// Prefix-alignment boost:
|
||||||
|
// Favour windows whose first few tokens closely match the beginning
|
||||||
|
// of the pattern, so we are less likely to cut off the first 1–2 words.
|
||||||
|
let boostedRating = adjustedRating;
|
||||||
|
if (patternTokens.length > 0) {
|
||||||
|
const windowTokens = tokenTexts.slice(start, start + windowSize);
|
||||||
|
const maxPrefixCheck = Math.min(
|
||||||
|
windowTokens.length,
|
||||||
|
patternTokens.length,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
let prefixMatches = 0;
|
||||||
|
for (let i = 0; i < maxPrefixCheck; i++) {
|
||||||
|
const tokenText = windowTokens[i];
|
||||||
|
const patternToken = patternTokens[i];
|
||||||
|
// Require reasonably strong similarity for a prefix match
|
||||||
|
const tokenSim = cmp.compare(tokenText, patternToken);
|
||||||
|
if (tokenSim >= 0.8) {
|
||||||
|
prefixMatches++;
|
||||||
|
} else {
|
||||||
|
// Stop at the first non-matching leading token
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefixMatches > 0) {
|
||||||
|
const prefixRatio = prefixMatches / maxPrefixCheck;
|
||||||
|
const PREFIX_BOOST_FACTOR = 0.25; // up to +25% boost
|
||||||
|
boostedRating = adjustedRating * (1 + prefixRatio * PREFIX_BOOST_FACTOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
boostedRating > bestRating ||
|
||||||
|
(Math.abs(boostedRating - bestRating) < 1e-3 &&
|
||||||
|
lengthDiff < bestLengthDiff)
|
||||||
|
) {
|
||||||
|
bestRating = boostedRating;
|
||||||
|
bestLengthDiff = lengthDiff;
|
||||||
|
bestStart = start;
|
||||||
|
bestEnd = start + offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: TokenMatchResponse = {
|
||||||
|
...responseBase,
|
||||||
|
bestStart,
|
||||||
|
bestEnd,
|
||||||
|
rating: bestRating,
|
||||||
|
lengthDiff: bestLengthDiff,
|
||||||
|
};
|
||||||
|
|
||||||
|
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
|
||||||
|
};
|
||||||
61
src/types/config.ts
Normal file
61
src/types/config.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { DocumentListState } from '@/types/documents';
|
||||||
|
|
||||||
|
export type ViewType = 'single' | 'dual' | 'scroll';
|
||||||
|
|
||||||
|
export type SavedVoices = Record<string, string>;
|
||||||
|
|
||||||
|
export interface AppConfigValues {
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
viewType: ViewType;
|
||||||
|
voiceSpeed: number;
|
||||||
|
audioPlayerSpeed: number;
|
||||||
|
voice: string;
|
||||||
|
skipBlank: boolean;
|
||||||
|
epubTheme: boolean;
|
||||||
|
headerMargin: number;
|
||||||
|
footerMargin: number;
|
||||||
|
leftMargin: number;
|
||||||
|
rightMargin: number;
|
||||||
|
ttsProvider: string;
|
||||||
|
ttsModel: string;
|
||||||
|
ttsInstructions: string;
|
||||||
|
savedVoices: SavedVoices;
|
||||||
|
smartSentenceSplitting: boolean;
|
||||||
|
pdfHighlightEnabled: boolean;
|
||||||
|
firstVisit: boolean;
|
||||||
|
documentListState: DocumentListState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: '',
|
||||||
|
viewType: 'single',
|
||||||
|
voiceSpeed: 1,
|
||||||
|
audioPlayerSpeed: 1,
|
||||||
|
voice: '',
|
||||||
|
skipBlank: true,
|
||||||
|
epubTheme: false,
|
||||||
|
headerMargin: 0,
|
||||||
|
footerMargin: 0,
|
||||||
|
leftMargin: 0,
|
||||||
|
rightMargin: 0,
|
||||||
|
ttsProvider: 'custom-openai',
|
||||||
|
ttsModel: 'kokoro',
|
||||||
|
ttsInstructions: '',
|
||||||
|
savedVoices: {},
|
||||||
|
smartSentenceSplitting: true,
|
||||||
|
pdfHighlightEnabled: true,
|
||||||
|
firstVisit: false,
|
||||||
|
documentListState: {
|
||||||
|
sortBy: 'name',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
folders: [],
|
||||||
|
collapsedFolders: [],
|
||||||
|
showHint: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AppConfigRow extends AppConfigValues {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,20 @@ export interface DOCXDocument extends BaseDocument {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AnyDocument =
|
||||||
|
| PDFDocument
|
||||||
|
| EPUBDocument
|
||||||
|
| HTMLDocument
|
||||||
|
| DOCXDocument;
|
||||||
|
|
||||||
|
export type BinaryDocument = PDFDocument | EPUBDocument | DOCXDocument;
|
||||||
|
|
||||||
|
// Representation used when syncing binary documents to/from the server.
|
||||||
|
// Data is converted from ArrayBuffer to a numeric array for JSON transport.
|
||||||
|
export interface SyncedDocument extends BaseDocument {
|
||||||
|
data: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentListDocument extends BaseDocument {
|
export interface DocumentListDocument extends BaseDocument {
|
||||||
type: DocumentType;
|
type: DocumentType;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/types/tts.ts
Normal file
74
src/types/tts.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
export type TTSLocation = string | number;
|
||||||
|
|
||||||
|
// Standardized error codes for the TTS API
|
||||||
|
export type TTSErrorCode =
|
||||||
|
| 'MISSING_PARAMETERS'
|
||||||
|
| 'INVALID_REQUEST'
|
||||||
|
| 'TTS_GENERATION_FAILED'
|
||||||
|
| 'ABORTED'
|
||||||
|
| 'INTERNAL_ERROR';
|
||||||
|
|
||||||
|
// Structured error object returned by the TTS API
|
||||||
|
export interface TTSError {
|
||||||
|
code: TTSErrorCode;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supported output formats for the TTS endpoint
|
||||||
|
export type TTSRequestFormat = 'mp3' | 'aac';
|
||||||
|
|
||||||
|
// JSON payload accepted by the /api/tts endpoint
|
||||||
|
export interface TTSRequestPayload {
|
||||||
|
text: string;
|
||||||
|
voice: string;
|
||||||
|
speed: number;
|
||||||
|
model?: string | null;
|
||||||
|
format?: TTSRequestFormat;
|
||||||
|
instructions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers used when calling the /api/tts endpoint from the client
|
||||||
|
export type TTSRequestHeaders = Record<string, string>;
|
||||||
|
|
||||||
|
// Core playback state exposed by the TTS context
|
||||||
|
export interface TTSPlaybackState {
|
||||||
|
isPlaying: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
isBackgrounded: boolean;
|
||||||
|
currentSentence: string;
|
||||||
|
currDocPage: TTSLocation;
|
||||||
|
currDocPageNumber: number;
|
||||||
|
currDocPages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options for retrying TTS requests on failure in withRetry
|
||||||
|
export interface TTSRetryOptions {
|
||||||
|
maxRetries?: number;
|
||||||
|
initialDelay?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
backoffFactor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result of merging a continuation slice into the current text
|
||||||
|
export interface TTSSmartMergeResult {
|
||||||
|
text: string;
|
||||||
|
carried: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate for when a visual page/section turn should occur during audio playback
|
||||||
|
export interface TTSPageTurnEstimate {
|
||||||
|
location: TTSLocation;
|
||||||
|
sentenceIndex: number;
|
||||||
|
fraction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata for an audiobook chapter
|
||||||
|
export interface TTSAudiobookChapter {
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
duration?: number;
|
||||||
|
status: 'pending' | 'generating' | 'completed' | 'error';
|
||||||
|
bookId?: string;
|
||||||
|
format?: 'mp3' | 'm4b';
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,4 @@
|
||||||
/**
|
import type { TTSRetryOptions } from '@/types/tts';
|
||||||
* Utility functions for audio processing
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface AudioChunk {
|
|
||||||
buffer: ArrayBuffer;
|
|
||||||
title?: string;
|
|
||||||
startTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RetryOptions {
|
|
||||||
maxRetries?: number;
|
|
||||||
initialDelay?: number;
|
|
||||||
maxDelay?: number;
|
|
||||||
backoffFactor?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a function with exponential backoff retry logic
|
* Executes a function with exponential backoff retry logic
|
||||||
|
|
@ -23,7 +8,7 @@ interface RetryOptions {
|
||||||
*/
|
*/
|
||||||
export const withRetry = async <T>(
|
export const withRetry = async <T>(
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
options: RetryOptions = {}
|
options: TTSRetryOptions = {}
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const {
|
const {
|
||||||
maxRetries = 3,
|
maxRetries = 3,
|
||||||
|
|
@ -39,7 +24,13 @@ export const withRetry = async <T>(
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
// Do not retry on explicit cancellation/abort errors - surface them
|
||||||
|
// immediately so callers can stop work quickly when the user cancels.
|
||||||
|
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (attempt === maxRetries - 1) {
|
if (attempt === maxRetries - 1) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -55,78 +46,4 @@ export const withRetry = async <T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError || new Error('Operation failed after retries');
|
throw lastError || new Error('Operation failed after retries');
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Combines audio chunks into a single audio file
|
|
||||||
* @param audioChunks Array of audio chunks with metadata
|
|
||||||
* @param format Output format ('mp3' or 'm4b')
|
|
||||||
* @param setIsAudioCombining Optional callback to track combining state
|
|
||||||
* @returns Promise resolving to the combined audio buffer
|
|
||||||
*/
|
|
||||||
export const combineAudioChunks = async (
|
|
||||||
audioChunks: AudioChunk[],
|
|
||||||
format: 'mp3' | 'm4b',
|
|
||||||
setIsAudioCombining?: (state: boolean) => void
|
|
||||||
): Promise<ArrayBuffer> => {
|
|
||||||
if (setIsAudioCombining) {
|
|
||||||
setIsAudioCombining(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (format === 'm4b') {
|
|
||||||
// Filter out chunks without titles and silence buffers
|
|
||||||
const titledChunks = audioChunks.filter(chunk => chunk.title && chunk.buffer.byteLength > 48000);
|
|
||||||
|
|
||||||
let bookId: string | undefined;
|
|
||||||
|
|
||||||
// Upload each chunk sequentially and get book ID
|
|
||||||
for (const chunk of titledChunks) {
|
|
||||||
const response = await fetch('/api/audio/convert', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
chapterTitle: chunk.title,
|
|
||||||
buffer: Array.from(new Uint8Array(chunk.buffer)),
|
|
||||||
bookId // Will be undefined for first chunk, then set for subsequent ones
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to upload audio chunk');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
bookId = result.bookId; // Save book ID for subsequent chunks
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bookId) {
|
|
||||||
throw new Error('No book ID received from server');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the final combined M4B file
|
|
||||||
const m4bResponse = await fetch(`/api/audio/convert?bookId=${bookId}`);
|
|
||||||
if (!m4bResponse.ok) {
|
|
||||||
throw new Error('Failed to get combined M4B');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await m4bResponse.arrayBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// For MP3, just concatenate the buffers
|
|
||||||
const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.buffer.byteLength, 0);
|
|
||||||
const combinedBuffer = new Uint8Array(totalLength);
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of audioChunks) {
|
|
||||||
combinedBuffer.set(new Uint8Array(chunk.buffer), offset);
|
|
||||||
offset += chunk.buffer.byteLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
return combinedBuffer.buffer;
|
|
||||||
} finally {
|
|
||||||
if (setIsAudioCombining) setIsAudioCombining(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,857 +0,0 @@
|
||||||
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState } from '@/types/documents';
|
|
||||||
|
|
||||||
const DB_NAME = 'openreader-db';
|
|
||||||
const DB_VERSION = 3; // Increased version for new store
|
|
||||||
const PDF_STORE_NAME = 'pdf-documents';
|
|
||||||
const EPUB_STORE_NAME = 'epub-documents';
|
|
||||||
const HTML_STORE_NAME = 'html-documents';
|
|
||||||
const CONFIG_STORE_NAME = 'config';
|
|
||||||
|
|
||||||
export interface Config {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class IndexedDBService {
|
|
||||||
private db: IDBDatabase | null = null;
|
|
||||||
private initPromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
async init(): Promise<void> {
|
|
||||||
if (this.initPromise) {
|
|
||||||
return this.initPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.db) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initPromise = new Promise((resolve, reject) => {
|
|
||||||
console.log('Initializing IndexedDB...');
|
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('IndexedDB initialization error:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = (event) => {
|
|
||||||
console.log('IndexedDB initialized successfully');
|
|
||||||
this.db = (event.target as IDBOpenDBRequest).result;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
|
||||||
console.log('Upgrading IndexedDB schema...');
|
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(PDF_STORE_NAME)) {
|
|
||||||
console.log('Creating PDF documents store...');
|
|
||||||
db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(HTML_STORE_NAME)) {
|
|
||||||
console.log('Creating HTML documents store...');
|
|
||||||
db.createObjectStore(HTML_STORE_NAME, { keyPath: 'id' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(EPUB_STORE_NAME)) {
|
|
||||||
console.log('Creating EPUB documents store...');
|
|
||||||
db.createObjectStore(EPUB_STORE_NAME, { keyPath: 'id' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains(CONFIG_STORE_NAME)) {
|
|
||||||
console.log('Creating config store...');
|
|
||||||
db.createObjectStore(CONFIG_STORE_NAME, { keyPath: 'key' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.initPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Document Methods
|
|
||||||
async addDocument(document: PDFDocument): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Adding document to IndexedDB:', document.name);
|
|
||||||
const transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(PDF_STORE_NAME);
|
|
||||||
|
|
||||||
// Create a structured clone of the document to ensure proper storage
|
|
||||||
const request = store.put({
|
|
||||||
...document,
|
|
||||||
data: document.data // Store ArrayBuffer directly
|
|
||||||
});
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error adding document:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('Document added successfully:', document.name);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in addDocument transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDocument(id: string): Promise<PDFDocument | undefined> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Fetching document:', id);
|
|
||||||
const transaction = this.db!.transaction([PDF_STORE_NAME], 'readonly');
|
|
||||||
const store = transaction.objectStore(PDF_STORE_NAME);
|
|
||||||
const request = store.get(id);
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error fetching document:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
console.log('Document fetch result:', request.result ? 'found' : 'not found');
|
|
||||||
resolve(request.result);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in getDocument transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllDocuments(): Promise<PDFDocument[]> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Fetching all documents');
|
|
||||||
const transaction = this.db!.transaction([PDF_STORE_NAME], 'readonly');
|
|
||||||
const store = transaction.objectStore(PDF_STORE_NAME);
|
|
||||||
const request = store.getAll();
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error fetching all documents:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
console.log('Retrieved documents count:', request.result?.length || 0);
|
|
||||||
resolve(request.result || []);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in getAllDocuments transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeDocument(id: string): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Removing document:', id);
|
|
||||||
const locationKey = `lastLocation_${id}`;
|
|
||||||
|
|
||||||
// Create two transactions - one for document deletion, one for location cleanup
|
|
||||||
const docTransaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite');
|
|
||||||
const configTransaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite');
|
|
||||||
|
|
||||||
const docStore = docTransaction.objectStore(PDF_STORE_NAME);
|
|
||||||
const configStore = configTransaction.objectStore(CONFIG_STORE_NAME);
|
|
||||||
|
|
||||||
// Remove the document
|
|
||||||
const docRequest = docStore.delete(id);
|
|
||||||
// Remove any saved location
|
|
||||||
const locationRequest = configStore.delete(locationKey);
|
|
||||||
|
|
||||||
docRequest.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error removing document:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
locationRequest.onerror = (event) => {
|
|
||||||
console.warn('Error cleaning up location:', (event.target as IDBRequest).error);
|
|
||||||
// Don't reject here, as document deletion is the primary operation
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait for both transactions to complete
|
|
||||||
Promise.all([
|
|
||||||
new Promise<void>((res) => { docTransaction.oncomplete = () => res(); }),
|
|
||||||
new Promise<void>((res) => { configTransaction.oncomplete = () => res(); })
|
|
||||||
]).then(() => {
|
|
||||||
console.log('Document and location data removed successfully:', id);
|
|
||||||
resolve();
|
|
||||||
}).catch(reject);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in removeDocument transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add HTML Document Methods
|
|
||||||
async addHTMLDocument(document: HTMLDocument): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Adding HTML document to IndexedDB:', document.name);
|
|
||||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
|
||||||
|
|
||||||
const request = store.put({
|
|
||||||
...document,
|
|
||||||
data: document.data
|
|
||||||
});
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error adding HTML document:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('HTML document added successfully:', document.name);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in addHTMLDocument transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getHTMLDocument(id: string): Promise<HTMLDocument | undefined> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Fetching HTML document:', id);
|
|
||||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readonly');
|
|
||||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
|
||||||
const request = store.get(id);
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error fetching HTML document:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
console.log('HTML Document fetch result:', request.result ? 'found' : 'not found');
|
|
||||||
resolve(request.result);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in getHTMLDocument transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllHTMLDocuments(): Promise<HTMLDocument[]> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Fetching all HTML documents');
|
|
||||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readonly');
|
|
||||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
|
||||||
const request = store.getAll();
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error fetching all HTML documents:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
console.log('Retrieved HTML documents count:', request.result?.length || 0);
|
|
||||||
resolve(request.result || []);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in getAllHTMLDocuments transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeHTMLDocument(id: string): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Removing HTML document:', id);
|
|
||||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
|
||||||
const request = store.delete(id);
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error removing HTML document:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('HTML document removed successfully:', id);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in removeHTMLDocument transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearHTMLDocuments(): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Clearing all HTML documents');
|
|
||||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
|
||||||
const request = store.clear();
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error clearing HTML documents:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('All HTML documents cleared successfully');
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in clearHTMLDocuments transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add EPUB Document Methods
|
|
||||||
async addEPUBDocument(document: EPUBDocument): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
if (document.data.byteLength === 0) {
|
|
||||||
throw new Error('Cannot store empty ArrayBuffer');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Storing document:', {
|
|
||||||
name: document.name,
|
|
||||||
size: document.size,
|
|
||||||
actualSize: document.data.byteLength
|
|
||||||
});
|
|
||||||
|
|
||||||
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(EPUB_STORE_NAME);
|
|
||||||
|
|
||||||
// Create a structured clone of the document to ensure proper storage
|
|
||||||
const request = store.put({
|
|
||||||
...document,
|
|
||||||
data: document.data.slice(0) // Create a copy of the ArrayBuffer
|
|
||||||
});
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
console.error('Error storing document:', (event.target as IDBRequest).error);
|
|
||||||
reject((event.target as IDBRequest).error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('Document stored successfully');
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in addEPUBDocument:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEPUBDocument(id: string): Promise<EPUBDocument | undefined> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readonly');
|
|
||||||
const store = transaction.objectStore(EPUB_STORE_NAME);
|
|
||||||
const request = store.get(id);
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
console.error('Error fetching document:', (event.target as IDBRequest).error);
|
|
||||||
reject((event.target as IDBRequest).error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
const doc = request.result;
|
|
||||||
if (doc) {
|
|
||||||
console.log('Retrieved document from DB:', {
|
|
||||||
name: doc.name,
|
|
||||||
size: doc.size,
|
|
||||||
actualSize: doc.data.byteLength
|
|
||||||
});
|
|
||||||
}
|
|
||||||
resolve(doc);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in getEPUBDocument:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllEPUBDocuments(): Promise<EPUBDocument[]> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readonly');
|
|
||||||
const store = transaction.objectStore(EPUB_STORE_NAME);
|
|
||||||
const request = store.getAll();
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
reject((event.target as IDBRequest).error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
resolve(request.result || []);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeEPUBDocument(id: string): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Removing EPUB document:', id);
|
|
||||||
const locationKey = `lastLocation_${id}`;
|
|
||||||
|
|
||||||
// Create two transactions - one for document deletion, one for location cleanup
|
|
||||||
const docTransaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite');
|
|
||||||
const configTransaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite');
|
|
||||||
|
|
||||||
const docStore = docTransaction.objectStore(EPUB_STORE_NAME);
|
|
||||||
const configStore = configTransaction.objectStore(CONFIG_STORE_NAME);
|
|
||||||
|
|
||||||
// Remove the document
|
|
||||||
const docRequest = docStore.delete(id);
|
|
||||||
// Remove any saved location
|
|
||||||
const locationRequest = configStore.delete(locationKey);
|
|
||||||
|
|
||||||
docRequest.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error removing EPUB document:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
locationRequest.onerror = (event) => {
|
|
||||||
console.warn('Error cleaning up location:', (event.target as IDBRequest).error);
|
|
||||||
// Don't reject here, as document deletion is the primary operation
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait for both transactions to complete
|
|
||||||
Promise.all([
|
|
||||||
new Promise<void>((res) => { docTransaction.oncomplete = () => res(); }),
|
|
||||||
new Promise<void>((res) => { configTransaction.oncomplete = () => res(); })
|
|
||||||
]).then(() => {
|
|
||||||
console.log('EPUB document and location data removed successfully:', id);
|
|
||||||
resolve();
|
|
||||||
}).catch(reject);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in removeEPUBDocument transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config Methods
|
|
||||||
async setConfigItem(key: string, value: string): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Setting config item:', key);
|
|
||||||
const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(CONFIG_STORE_NAME);
|
|
||||||
const request = store.put({ key, value });
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error setting config item:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('Config item set successfully:', key);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in setConfigItem transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConfigItem(key: string): Promise<string | null> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Fetching config item:', key);
|
|
||||||
const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readonly');
|
|
||||||
const store = transaction.objectStore(CONFIG_STORE_NAME);
|
|
||||||
const request = store.get(key);
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error fetching config item:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
const result = request.result as Config | undefined;
|
|
||||||
console.log('Config item fetch result:', result ? 'found' : 'not found');
|
|
||||||
resolve(result ? result.value : null);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in getConfigItem transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllConfig(): Promise<Record<string, string>> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Fetching all config items');
|
|
||||||
const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readonly');
|
|
||||||
const store = transaction.objectStore(CONFIG_STORE_NAME);
|
|
||||||
const request = store.getAll();
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error fetching all config items:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
const result = request.result as Config[];
|
|
||||||
const config: Record<string, string> = {};
|
|
||||||
result.forEach((item) => {
|
|
||||||
config[item.key] = item.value;
|
|
||||||
});
|
|
||||||
console.log('Retrieved config items count:', result.length);
|
|
||||||
resolve(config);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in getAllConfig transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeConfigItem(key: string): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Removing config item:', key);
|
|
||||||
const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(CONFIG_STORE_NAME);
|
|
||||||
const request = store.delete(key);
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error removing config item:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('Config item removed successfully:', key);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in removeConfigItem transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncToServer(onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal): Promise<{ lastSync: number }> {
|
|
||||||
const pdfDocs = await this.getAllDocuments();
|
|
||||||
const epubDocs = await this.getAllEPUBDocuments();
|
|
||||||
|
|
||||||
const documents = [];
|
|
||||||
const totalDocs = pdfDocs.length + epubDocs.length;
|
|
||||||
let processedDocs = 0;
|
|
||||||
|
|
||||||
// Process PDF documents - convert ArrayBuffer to array for JSON serialization
|
|
||||||
for (const doc of pdfDocs) {
|
|
||||||
documents.push({
|
|
||||||
...doc,
|
|
||||||
type: 'pdf',
|
|
||||||
data: Array.from(new Uint8Array(doc.data))
|
|
||||||
});
|
|
||||||
processedDocs++;
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process EPUB documents
|
|
||||||
for (const doc of epubDocs) {
|
|
||||||
documents.push({
|
|
||||||
...doc,
|
|
||||||
type: 'epub',
|
|
||||||
data: Array.from(new Uint8Array(doc.data))
|
|
||||||
});
|
|
||||||
processedDocs++;
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(50, 'Uploading to server...');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/documents', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ documents }),
|
|
||||||
signal
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to sync documents to server');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(100, 'Upload complete!');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { lastSync: Date.now() };
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadFromServer(onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal): Promise<{ lastSync: number }> {
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(10, 'Starting download...');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/documents', { signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch documents from server');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(30, 'Download complete');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { documents } = await response.json();
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(40, 'Parsing documents...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each document
|
|
||||||
for (let i = 0; i < documents.length; i++) {
|
|
||||||
const doc = documents[i];
|
|
||||||
// Convert the numeric array back to ArrayBuffer
|
|
||||||
const uint8Array = new Uint8Array(doc.data);
|
|
||||||
const documentData = {
|
|
||||||
id: doc.id,
|
|
||||||
type: doc.type,
|
|
||||||
name: doc.name,
|
|
||||||
size: doc.size,
|
|
||||||
lastModified: doc.lastModified,
|
|
||||||
data: uint8Array.buffer
|
|
||||||
};
|
|
||||||
|
|
||||||
if (doc.type === 'pdf') {
|
|
||||||
await this.addDocument(documentData);
|
|
||||||
} else if (doc.type === 'epub') {
|
|
||||||
await this.addEPUBDocument(documentData);
|
|
||||||
} else {
|
|
||||||
console.warn(`Unknown document type: ${doc.type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
// Progress from 40% to 90% for document processing
|
|
||||||
onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(100, 'Load complete!');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { lastSync: Date.now() };
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearPDFDocuments(): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Clearing all PDF documents');
|
|
||||||
const transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(PDF_STORE_NAME);
|
|
||||||
const request = store.clear();
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error clearing PDF documents:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('All PDF documents cleared successfully');
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in clearPDFDocuments transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearEPUBDocuments(): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
await this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
console.log('Clearing all EPUB documents');
|
|
||||||
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite');
|
|
||||||
const store = transaction.objectStore(EPUB_STORE_NAME);
|
|
||||||
const request = store.clear();
|
|
||||||
|
|
||||||
request.onerror = (event) => {
|
|
||||||
const error = (event.target as IDBRequest).error;
|
|
||||||
console.error('Error clearing EPUB documents:', error);
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
console.log('All EPUB documents cleared successfully');
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in clearEPUBDocuments transaction:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveDocumentListState(state: DocumentListState): Promise<void> {
|
|
||||||
return this.setConfigItem('documentListState', JSON.stringify(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDocumentListState(): Promise<DocumentListState | null> {
|
|
||||||
const stateStr = await this.getConfigItem('documentListState');
|
|
||||||
if (!stateStr) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(stateStr);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing document list state:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure we export a singleton instance
|
|
||||||
const indexedDBServiceInstance = new IndexedDBService();
|
|
||||||
export const indexedDBService = indexedDBServiceInstance;
|
|
||||||
|
|
||||||
// Helper functions for the ConfigContext
|
|
||||||
export async function getItem(key: string): Promise<string | null> {
|
|
||||||
return indexedDBService.getConfigItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setItem(key: string, value: string): Promise<void> {
|
|
||||||
return indexedDBService.setConfigItem(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeItem(key: string): Promise<void> {
|
|
||||||
return indexedDBService.removeConfigItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add these helper functions before the final export
|
|
||||||
export async function getLastDocumentLocation(docId: string): Promise<string | null> {
|
|
||||||
const key = `lastLocation_${docId}`;
|
|
||||||
return indexedDBService.getConfigItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setLastDocumentLocation(docId: string, location: string): Promise<void> {
|
|
||||||
const key = `lastLocation_${docId}`;
|
|
||||||
return indexedDBService.setConfigItem(key, location);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDocumentListState(): Promise<DocumentListState | null> {
|
|
||||||
return indexedDBService.getDocumentListState();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveDocumentListState(state: DocumentListState): Promise<void> {
|
|
||||||
return indexedDBService.saveDocumentListState(state);
|
|
||||||
}
|
|
||||||
362
src/utils/pdf.ts
362
src/utils/pdf.ts
|
|
@ -1,362 +0,0 @@
|
||||||
import { pdfjs } from 'react-pdf';
|
|
||||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
|
||||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
|
||||||
import "core-js/proposals/promise-with-resolvers";
|
|
||||||
import { processTextToSentences } from '@/utils/nlp';
|
|
||||||
import { CmpStr } from 'cmpstr';
|
|
||||||
|
|
||||||
const cmp = CmpStr.create().setMetric( 'levenshtein' ).setFlags( 'i' );
|
|
||||||
|
|
||||||
// Function to detect if we need to use legacy build
|
|
||||||
function shouldUseLegacyBuild() {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
|
|
||||||
const ua = window.navigator.userAgent;
|
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
|
|
||||||
|
|
||||||
console.log(isSafari ? 'Running on Safari' : 'Not running on Safari');
|
|
||||||
if (!isSafari) return false;
|
|
||||||
|
|
||||||
// Extract Safari version - matches "Version/18" format
|
|
||||||
const match = ua.match(/Version\/(\d+)/i);
|
|
||||||
console.log('Safari version:', match);
|
|
||||||
if (!match || !match[1]) return true; // If we can't determine version, use legacy to be safe
|
|
||||||
|
|
||||||
const version = parseInt(match[1]);
|
|
||||||
return version < 18; // Use legacy build for Safari versions equal or below 18
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error detecting Safari version:', e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to initialize PDF worker
|
|
||||||
function initPDFWorker() {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const useLegacy = shouldUseLegacyBuild();
|
|
||||||
// Use local worker file instead of unpkg
|
|
||||||
const workerSrc = useLegacy
|
|
||||||
? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href
|
|
||||||
: new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href;
|
|
||||||
console.log('Setting PDF worker to:', workerSrc);
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
|
||||||
pdfjs.GlobalWorkerOptions.workerPort = null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error setting PDF worker:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the worker
|
|
||||||
initPDFWorker();
|
|
||||||
|
|
||||||
interface TextMatch {
|
|
||||||
elements: HTMLElement[];
|
|
||||||
rating: number;
|
|
||||||
text: string;
|
|
||||||
lengthDiff: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text Processing functions
|
|
||||||
export async function extractTextFromPDF(
|
|
||||||
pdf: PDFDocumentProxy,
|
|
||||||
pageNumber: number,
|
|
||||||
margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 }
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
// Log pdf worker version
|
|
||||||
//console.log('PDF worker version:', pdfjs.GlobalWorkerOptions.workerSrc);
|
|
||||||
|
|
||||||
const page = await pdf.getPage(pageNumber);
|
|
||||||
const textContent = await page.getTextContent();
|
|
||||||
|
|
||||||
const viewport = page.getViewport({ scale: 1.0 });
|
|
||||||
const pageHeight = viewport.height;
|
|
||||||
const pageWidth = viewport.width;
|
|
||||||
|
|
||||||
const textItems = textContent.items.filter((item): item is TextItem => {
|
|
||||||
if (!('str' in item && 'transform' in item)) return false;
|
|
||||||
|
|
||||||
const [scaleX, skewX, skewY, scaleY, x, y] = item.transform;
|
|
||||||
|
|
||||||
// Basic text filtering
|
|
||||||
if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false;
|
|
||||||
if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false;
|
|
||||||
if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false;
|
|
||||||
|
|
||||||
// Calculate margins in PDF coordinate space (y=0 is at bottom)
|
|
||||||
const headerY = pageHeight * (1 - margins.header); // Convert from top margin to bottom-based Y
|
|
||||||
const footerY = pageHeight * margins.footer; // Footer Y stays as is since it's already bottom-based
|
|
||||||
const leftX = pageWidth * margins.left;
|
|
||||||
const rightX = pageWidth * (1 - margins.right);
|
|
||||||
|
|
||||||
// Check margins - remember y=0 is at bottom of page in PDF coordinates
|
|
||||||
if (y > headerY || y < footerY) { // Y greater than headerY means it's in header area, less than footerY means footer area
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check horizontal margins
|
|
||||||
if (x < leftX || x > rightX) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanity check for coordinates
|
|
||||||
if (x < 0 || x > pageWidth) return false;
|
|
||||||
|
|
||||||
return item.str.trim().length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
//console.log('Filtered text items:', textItems);
|
|
||||||
|
|
||||||
const tolerance = 2;
|
|
||||||
const lines: TextItem[][] = [];
|
|
||||||
let currentLine: TextItem[] = [];
|
|
||||||
let currentY: number | null = null;
|
|
||||||
|
|
||||||
textItems.forEach((item) => {
|
|
||||||
const y = item.transform[5];
|
|
||||||
if (currentY === null) {
|
|
||||||
currentY = y;
|
|
||||||
currentLine.push(item);
|
|
||||||
} else if (Math.abs(y - currentY) < tolerance) {
|
|
||||||
currentLine.push(item);
|
|
||||||
} else {
|
|
||||||
lines.push(currentLine);
|
|
||||||
currentLine = [item];
|
|
||||||
currentY = y;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
lines.push(currentLine);
|
|
||||||
|
|
||||||
let pageText = '';
|
|
||||||
for (const line of lines) {
|
|
||||||
line.sort((a, b) => a.transform[4] - b.transform[4]);
|
|
||||||
let lineText = '';
|
|
||||||
let prevItem: TextItem | null = null;
|
|
||||||
|
|
||||||
for (const item of line) {
|
|
||||||
if (!prevItem) {
|
|
||||||
lineText = item.str;
|
|
||||||
} else {
|
|
||||||
const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0);
|
|
||||||
const currentStartX = item.transform[4];
|
|
||||||
const space = currentStartX - prevEndX;
|
|
||||||
|
|
||||||
// Get average character width as fallback
|
|
||||||
const avgCharWidth = (item.width ?? 0) / Math.max(1, item.str.length);
|
|
||||||
|
|
||||||
// Multiple conditions for space detection
|
|
||||||
const needsSpace =
|
|
||||||
// Primary check: significant gap between items
|
|
||||||
space > Math.max(avgCharWidth * 0.3, 2) ||
|
|
||||||
// Secondary check: natural word boundary
|
|
||||||
(!/^\W/.test(item.str) && !/\W$/.test(prevItem.str)) ||
|
|
||||||
// Tertiary check: items are far enough apart relative to their size
|
|
||||||
(space > ((prevItem.width ?? 0) * 0.25));
|
|
||||||
|
|
||||||
if (needsSpace) {
|
|
||||||
lineText += ' ' + item.str;
|
|
||||||
} else {
|
|
||||||
lineText += item.str;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevItem = item;
|
|
||||||
}
|
|
||||||
pageText += lineText + ' ';
|
|
||||||
}
|
|
||||||
|
|
||||||
return pageText.replace(/\s+/g, ' ').trim();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error extracting text from PDF:', error);
|
|
||||||
throw new Error('Failed to extract text from PDF');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlighting functions
|
|
||||||
export function clearHighlights() {
|
|
||||||
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
|
|
||||||
textNodes.forEach((node) => {
|
|
||||||
const element = node as HTMLElement;
|
|
||||||
element.style.backgroundColor = '';
|
|
||||||
element.style.opacity = '1';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findBestTextMatch(
|
|
||||||
elements: Array<{ element: HTMLElement; text: string }>,
|
|
||||||
targetText: string,
|
|
||||||
maxCombinedLength: number
|
|
||||||
): TextMatch {
|
|
||||||
let bestMatch = {
|
|
||||||
elements: [] as HTMLElement[],
|
|
||||||
rating: 0,
|
|
||||||
text: '',
|
|
||||||
lengthDiff: Infinity,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SPAN_SEARCH_LIMIT = 10;
|
|
||||||
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
|
||||||
let combinedText = '';
|
|
||||||
const currentElements = [];
|
|
||||||
for (let j = i; j < Math.min(i + SPAN_SEARCH_LIMIT, elements.length); j++) {
|
|
||||||
const node = elements[j];
|
|
||||||
const newText = combinedText ? `${combinedText} ${node.text}` : node.text;
|
|
||||||
if (newText.length > maxCombinedLength) break;
|
|
||||||
|
|
||||||
combinedText = newText;
|
|
||||||
currentElements.push(node.element);
|
|
||||||
|
|
||||||
const similarity = cmp.compare(combinedText, targetText);
|
|
||||||
const lengthDiff = Math.abs(combinedText.length - targetText.length);
|
|
||||||
const lengthPenalty = lengthDiff / targetText.length;
|
|
||||||
const adjustedRating = similarity * (1 - lengthPenalty * 0.5);
|
|
||||||
|
|
||||||
if (adjustedRating > bestMatch.rating) {
|
|
||||||
bestMatch = {
|
|
||||||
elements: [...currentElements],
|
|
||||||
rating: adjustedRating,
|
|
||||||
text: combinedText,
|
|
||||||
lengthDiff,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function highlightPattern(
|
|
||||||
text: string,
|
|
||||||
pattern: string,
|
|
||||||
containerRef: React.RefObject<HTMLDivElement>
|
|
||||||
) {
|
|
||||||
clearHighlights();
|
|
||||||
|
|
||||||
if (!pattern?.trim()) return;
|
|
||||||
|
|
||||||
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span');
|
|
||||||
const allText = Array.from(textNodes).map((node) => ({
|
|
||||||
element: node as HTMLElement,
|
|
||||||
text: (node.textContent || '').trim(),
|
|
||||||
})).filter((node) => node.text.length > 0);
|
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const visibleTop = container.scrollTop;
|
|
||||||
const visibleBottom = visibleTop + containerRect.height;
|
|
||||||
const bufferSize = containerRect.height;
|
|
||||||
|
|
||||||
const visibleNodes = allText.filter(({ element }) => {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const elementTop = rect.top - containerRect.top + container.scrollTop;
|
|
||||||
return elementTop >= (visibleTop - bufferSize) && elementTop <= (visibleBottom + bufferSize);
|
|
||||||
});
|
|
||||||
|
|
||||||
let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2);
|
|
||||||
|
|
||||||
if (bestMatch.rating < 0.3) {
|
|
||||||
bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const similarityThreshold = bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5;
|
|
||||||
|
|
||||||
if (bestMatch.rating >= similarityThreshold) {
|
|
||||||
bestMatch.elements.forEach((element) => {
|
|
||||||
element.style.backgroundColor = 'grey';
|
|
||||||
element.style.opacity = '0.4';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (bestMatch.elements.length > 0) {
|
|
||||||
const element = bestMatch.elements[0];
|
|
||||||
const elementRect = element.getBoundingClientRect();
|
|
||||||
const elementTop = elementRect.top - containerRect.top + container.scrollTop;
|
|
||||||
|
|
||||||
if (elementTop < visibleTop || elementTop > visibleBottom) {
|
|
||||||
container.scrollTo({
|
|
||||||
top: elementTop - containerRect.height / 3,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text Click Handler
|
|
||||||
export function handleTextClick(
|
|
||||||
event: MouseEvent,
|
|
||||||
pdfText: string,
|
|
||||||
containerRef: React.RefObject<HTMLDivElement>,
|
|
||||||
stopAndPlayFromIndex: (index: number) => void,
|
|
||||||
isProcessing: boolean
|
|
||||||
) {
|
|
||||||
if (isProcessing) return;
|
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (!target.matches('.react-pdf__Page__textContent span')) return;
|
|
||||||
|
|
||||||
const parentElement = target.closest('.react-pdf__Page__textContent');
|
|
||||||
if (!parentElement) return;
|
|
||||||
|
|
||||||
const spans = Array.from(parentElement.querySelectorAll('span'));
|
|
||||||
const clickedIndex = spans.indexOf(target);
|
|
||||||
const contextWindow = 3;
|
|
||||||
const startIndex = Math.max(0, clickedIndex - contextWindow);
|
|
||||||
const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow);
|
|
||||||
const contextText = spans
|
|
||||||
.slice(startIndex, endIndex + 1)
|
|
||||||
.map((span) => span.textContent)
|
|
||||||
.join(' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (!contextText?.trim()) return;
|
|
||||||
|
|
||||||
const cleanContext = contextText.trim().replace(/\s+/g, ' ');
|
|
||||||
const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({
|
|
||||||
element: node as HTMLElement,
|
|
||||||
text: (node.textContent || '').trim(),
|
|
||||||
})).filter((node) => node.text.length > 0);
|
|
||||||
|
|
||||||
const bestMatch = findBestTextMatch(allText, cleanContext, cleanContext.length * 2);
|
|
||||||
const similarityThreshold = bestMatch.lengthDiff < cleanContext.length * 0.3 ? 0.3 : 0.5;
|
|
||||||
|
|
||||||
if (bestMatch.rating >= similarityThreshold) {
|
|
||||||
const matchText = bestMatch.text;
|
|
||||||
// Use the same sentence processing logic as TTSContext for consistency
|
|
||||||
const sentences = processTextToSentences(pdfText);
|
|
||||||
console.log("sentences inside handleTextClick: %d", sentences.length)
|
|
||||||
let bestSentenceMatch = { sentence: '', rating: 0 };
|
|
||||||
|
|
||||||
for (const sentence of sentences) {
|
|
||||||
const rating = cmp.compare(matchText, sentence);
|
|
||||||
if (rating > bestSentenceMatch.rating) {
|
|
||||||
bestSentenceMatch = { sentence, rating };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestSentenceMatch.rating >= 0.5) {
|
|
||||||
const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence);
|
|
||||||
if (sentenceIndex !== -1) {
|
|
||||||
stopAndPlayFromIndex(sentenceIndex);
|
|
||||||
highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce for PDF viewer
|
|
||||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
|
||||||
func: T,
|
|
||||||
wait: number
|
|
||||||
): (...args: Parameters<T>) => void {
|
|
||||||
let timeout: NodeJS.Timeout;
|
|
||||||
return (...args: Parameters<T>) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(() => func(...args), wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
64
src/utils/voice.ts
Normal file
64
src/utils/voice.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Voice Utilities
|
||||||
|
*
|
||||||
|
* This module provides utilities for handling voice selection and management,
|
||||||
|
* particularly for Kokoro multi-voice syntax.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a Kokoro voice string into individual voice names
|
||||||
|
* Strips weights like "af_heart(0.5)" -> "af_heart"
|
||||||
|
*
|
||||||
|
* @param voiceString - Voice string to parse (e.g., "af_heart(0.5)+bf_emma(0.5)")
|
||||||
|
* @returns Array of voice names without weights
|
||||||
|
*/
|
||||||
|
export const parseKokoroVoiceNames = (voiceString: string): string[] =>
|
||||||
|
voiceString
|
||||||
|
.split('+')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(s => s.replace(/\([^)]*\)/g, '').trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Kokoro voice string from an array of voice names
|
||||||
|
* Automatically calculates equal weights for multiple voices
|
||||||
|
*
|
||||||
|
* @param names - Array of voice names
|
||||||
|
* @returns Formatted voice string with weights or single voice name
|
||||||
|
*/
|
||||||
|
export const buildKokoroVoiceString = (names: string[]): string => {
|
||||||
|
const n = names.length;
|
||||||
|
if (n === 0) return '';
|
||||||
|
if (n === 1) return names[0];
|
||||||
|
|
||||||
|
const weight = 1 / n;
|
||||||
|
const weightString = weight.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
return names.map(name => `${name}(${weightString})`).join('+');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a model name is a Kokoro model
|
||||||
|
*
|
||||||
|
* @param modelName - TTS model name
|
||||||
|
* @returns True if the model is a Kokoro model
|
||||||
|
*/
|
||||||
|
export const isKokoroModel = (modelName: string | undefined): boolean => {
|
||||||
|
return (modelName || '').toLowerCase().includes('kokoro');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the maximum number of voices allowed for a provider/model combination
|
||||||
|
*
|
||||||
|
* @param provider - TTS provider name
|
||||||
|
* @param model - TTS model name
|
||||||
|
* @returns Maximum number of voices (Infinity for unlimited)
|
||||||
|
*/
|
||||||
|
export const getMaxVoicesForProvider = (provider: string, model: string): number => {
|
||||||
|
if (!isKokoroModel(model)) return 1;
|
||||||
|
|
||||||
|
// Deepinfra Kokoro does not support multiple voices
|
||||||
|
if (provider === 'deepinfra') return 1;
|
||||||
|
|
||||||
|
// Other providers with Kokoro support unlimited voices
|
||||||
|
return Infinity;
|
||||||
|
};
|
||||||
|
|
@ -16,6 +16,7 @@ export default {
|
||||||
base: "var(--base)",
|
base: "var(--base)",
|
||||||
offbase: "var(--offbase)",
|
offbase: "var(--offbase)",
|
||||||
accent: "var(--accent)",
|
accent: "var(--accent)",
|
||||||
|
"secondary-accent": "var(--secondary-accent)",
|
||||||
muted: "var(--muted)",
|
muted: "var(--muted)",
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
|
|
||||||
88
tests/accessibility.spec.ts
Normal file
88
tests/accessibility.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
setupTest,
|
||||||
|
uploadFiles,
|
||||||
|
ensureDocumentsListed,
|
||||||
|
uploadAndDisplay,
|
||||||
|
expectProcessingTransition,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
test.describe('Accessibility smoke', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropzone input and hint text are accessible', async ({ page }) => {
|
||||||
|
// Input is present and visible
|
||||||
|
await expect(page.locator('input[type="file"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Hint text present (supports compact or default variants)
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Drop your file\(s\) here|Drop files or click|Drop your file\(s\) here, or click to select/i)
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document links have roles and accessible names', async ({ page }) => {
|
||||||
|
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
||||||
|
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: /sample\.pdf/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /sample\.epub/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /sample\.txt/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ConfirmDialog exposes role=dialog with title and actions', async ({ page }) => {
|
||||||
|
await uploadFiles(page, 'sample.pdf');
|
||||||
|
await ensureDocumentsListed(page, ['sample.pdf']);
|
||||||
|
|
||||||
|
// Open the confirm dialog by clicking the row delete button
|
||||||
|
await page.getByRole('button', { name: 'Delete document' }).first().click();
|
||||||
|
|
||||||
|
// Title and dialog role visible
|
||||||
|
const heading = page.getByRole('heading', { name: 'Delete Document' });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||||
|
const dialog = heading.locator('xpath=ancestor::*[@role="dialog"][1]');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
// Has a destructive action (Delete)
|
||||||
|
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||||
|
|
||||||
|
// Close with Escape to avoid deleting test data
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TTS controls expose aria labels and are keyboard focusable', async ({ page }) => {
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
|
// TTS bar present
|
||||||
|
const ttsbar = page.locator('[data-app-ttsbar]');
|
||||||
|
await expect(ttsbar).toBeVisible();
|
||||||
|
|
||||||
|
// Verify control labels
|
||||||
|
const backBtn = page.getByRole('button', { name: 'Skip backward' });
|
||||||
|
const playBtn = page.getByRole('button', { name: 'Play' });
|
||||||
|
const fwdBtn = page.getByRole('button', { name: 'Skip forward' });
|
||||||
|
|
||||||
|
await expect(backBtn).toBeVisible();
|
||||||
|
await expect(playBtn).toBeVisible();
|
||||||
|
await expect(fwdBtn).toBeVisible();
|
||||||
|
|
||||||
|
// Keyboard focus checks
|
||||||
|
await page.focus('button[aria-label="Skip backward"]');
|
||||||
|
await expect(backBtn).toBeFocused();
|
||||||
|
|
||||||
|
await page.focus('button[aria-label="Play"]');
|
||||||
|
await expect(playBtn).toBeFocused();
|
||||||
|
|
||||||
|
await page.focus('button[aria-label="Skip forward"]');
|
||||||
|
await expect(fwdBtn).toBeFocused();
|
||||||
|
|
||||||
|
// Toggle play and verify aria-label swap to Pause, then back to Play
|
||||||
|
await playBtn.click();
|
||||||
|
await expectProcessingTransition(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Pause' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
20
tests/api.spec.ts
Normal file
20
tests/api.spec.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('API health checks', () => {
|
||||||
|
test('GET /api/tts/voices returns 200 and a non-empty voices array', async ({ request }) => {
|
||||||
|
const res = await request.get('/api/tts/voices');
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const json = await res.json();
|
||||||
|
expect(Array.isArray(json.voices)).toBeTruthy();
|
||||||
|
expect(json.voices.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/audio/convert/chapters returns 200 with exists flag and chapters array', async ({ request }) => {
|
||||||
|
const bookId = `healthcheck-${Date.now()}`;
|
||||||
|
const res = await request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json).toHaveProperty('exists');
|
||||||
|
expect(Array.isArray(json.chapters)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
48
tests/delete.spec.ts
Normal file
48
tests/delete.spec.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName, deleteAllLocalDocuments, ensureDocumentsListed } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Document deletion flow', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a document and updates list', async ({ page }) => {
|
||||||
|
// Upload two documents
|
||||||
|
await uploadFile(page, 'sample.pdf');
|
||||||
|
await uploadFile(page, 'sample.txt');
|
||||||
|
|
||||||
|
// Verify both appear
|
||||||
|
await expectDocumentListed(page, 'sample.pdf');
|
||||||
|
await expectDocumentListed(page, 'sample.txt');
|
||||||
|
|
||||||
|
// Delete the TXT document via row action
|
||||||
|
await deleteDocumentByName(page, 'sample.txt');
|
||||||
|
|
||||||
|
// Assert the TXT document is removed, PDF remains
|
||||||
|
await expectNoDocumentLink(page, 'sample.txt');
|
||||||
|
await expectDocumentListed(page, 'sample.pdf');
|
||||||
|
|
||||||
|
// Optional: summary exists (best-effort)
|
||||||
|
const summary = page.locator('[data-doc-summary]');
|
||||||
|
await expect(summary).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes all local documents from Settings modal', async ({ page }) => {
|
||||||
|
// Upload multiple docs (PDF + EPUB)
|
||||||
|
await uploadFile(page, 'sample.pdf');
|
||||||
|
await uploadFile(page, 'sample.epub');
|
||||||
|
|
||||||
|
// Verify both appear
|
||||||
|
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']);
|
||||||
|
|
||||||
|
// Delete all local documents via Settings
|
||||||
|
await deleteAllLocalDocuments(page);
|
||||||
|
|
||||||
|
// Assert both documents are removed
|
||||||
|
await expectNoDocumentLink(page, 'sample.pdf');
|
||||||
|
await expectNoDocumentLink(page, 'sample.epub');
|
||||||
|
|
||||||
|
// Uploader should be visible when no docs remain
|
||||||
|
await expect(page.locator('input[type=file]')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
353
tests/export.spec.ts
Normal file
353
tests/export.spec.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import fs from 'fs';
|
||||||
|
import util from 'util';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { setupTest, uploadAndDisplay } from './helpers';
|
||||||
|
|
||||||
|
const execFileAsync = util.promisify(execFile);
|
||||||
|
|
||||||
|
async function getBookIdFromUrl(page: Page, expectedPrefix: 'pdf' | 'epub') {
|
||||||
|
const url = new URL(page.url());
|
||||||
|
const segments = url.pathname.split('/').filter(Boolean);
|
||||||
|
expect(segments[0]).toBe(expectedPrefix);
|
||||||
|
const bookId = segments[1];
|
||||||
|
expect(bookId).toBeTruthy();
|
||||||
|
return bookId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openExportModal(page: Page) {
|
||||||
|
const exportButton = page.getByRole('button', { name: 'Open audiobook export' });
|
||||||
|
await expect(exportButton).toBeVisible({ timeout: 15_000 });
|
||||||
|
await exportButton.click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Export Audiobook' })).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setContainerFormatToMP3(page: Page) {
|
||||||
|
const formatTrigger = page.getByRole('button', { name: /M4B|MP3/i });
|
||||||
|
await expect(formatTrigger).toBeVisible({ timeout: 15_000 });
|
||||||
|
await formatTrigger.click();
|
||||||
|
await page.getByRole('option', { name: 'MP3' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGeneration(page: Page) {
|
||||||
|
const startButton = page.getByRole('button', { name: 'Start Generation' });
|
||||||
|
await expect(startButton).toBeVisible({ timeout: 15_000 });
|
||||||
|
await startButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForChaptersHeading(page: Page) {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFullAudiobook(page: Page, timeoutMs = 60_000) {
|
||||||
|
const fullDownloadButton = page.getByRole('button', { name: /Full Download/i });
|
||||||
|
await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs });
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download', { timeout: timeoutMs }),
|
||||||
|
fullDownloadButton.click(),
|
||||||
|
]);
|
||||||
|
const downloadedPath = await download.path();
|
||||||
|
expect(downloadedPath).toBeTruthy();
|
||||||
|
const stats = fs.statSync(downloadedPath!);
|
||||||
|
expect(stats.size).toBeGreaterThan(0);
|
||||||
|
return downloadedPath!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAudioDurationSeconds(filePath: string) {
|
||||||
|
const { stdout } = await execFileAsync('ffprobe', [
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-show_entries',
|
||||||
|
'format=duration',
|
||||||
|
'-of',
|
||||||
|
'csv=p=0',
|
||||||
|
filePath,
|
||||||
|
]);
|
||||||
|
return parseFloat(stdout.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectChaptersBackendState(page: Page, bookId: string) {
|
||||||
|
const res = await page.request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const json = await res.json();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAudiobookIfPresent(page: Page) {
|
||||||
|
const resetButtons = page.getByRole('button', { name: 'Reset' });
|
||||||
|
const count = await resetButtons.count();
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetButton = resetButtons.first();
|
||||||
|
await resetButton.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15_000 });
|
||||||
|
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
|
||||||
|
await confirmReset.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Click "Start Generation" to begin creating your audiobook/i)
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Audiobook export', () => {
|
||||||
|
test.describe.configure({ mode: 'serial', timeout: 120_000 });
|
||||||
|
|
||||||
|
test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => {
|
||||||
|
// Ensure TTS is mocked and app is ready
|
||||||
|
await setupTest(page);
|
||||||
|
|
||||||
|
// Upload and open the sample PDF in the viewer
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
|
// Capture the generated document/book id from the /pdf/[id] URL
|
||||||
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
|
||||||
|
// Open the audiobook export modal from the header button
|
||||||
|
await openExportModal(page);
|
||||||
|
|
||||||
|
// While there are no chapters yet, we can still switch the container format.
|
||||||
|
// Choose MP3 so we can validate MP3 duration end-to-end.
|
||||||
|
await setContainerFormatToMP3(page);
|
||||||
|
|
||||||
|
// Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page
|
||||||
|
await startGeneration(page);
|
||||||
|
|
||||||
|
// Wait for chapters list to appear and populate at least two items (Pages 1 and 2)
|
||||||
|
await waitForChaptersHeading(page);
|
||||||
|
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||||
|
await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 });
|
||||||
|
|
||||||
|
// Trigger full download from the FRONTEND button and capture via Playwright's download API.
|
||||||
|
// The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on
|
||||||
|
// the server-side detected format, so match more loosely on the accessible name.
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
|
// Use ffprobe (same toolchain as the server) to validate the combined audio duration.
|
||||||
|
// The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least
|
||||||
|
// two chapters we should be close to ~20 seconds of audio.
|
||||||
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
|
// Duration must be within a reasonable window around 20 seconds to allow
|
||||||
|
// for encoding variations and container overhead.
|
||||||
|
expect(durationSeconds).toBeGreaterThan(18);
|
||||||
|
expect(durationSeconds).toBeLessThan(22);
|
||||||
|
|
||||||
|
// Also check the chapter metadata API for consistency
|
||||||
|
const json = await expectChaptersBackendState(page, bookId);
|
||||||
|
expect(json.exists).toBe(true);
|
||||||
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
|
expect(json.chapters.length).toBeGreaterThanOrEqual(2);
|
||||||
|
for (const ch of json.chapters) {
|
||||||
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetAudiobookIfPresent(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles partial EPUB audiobook generation, cancel, and full download of partial audiobook', async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
|
||||||
|
// Upload and open the sample EPUB in the viewer
|
||||||
|
await uploadAndDisplay(page, 'sample.epub');
|
||||||
|
|
||||||
|
// URL should now be /epub/[id]
|
||||||
|
const bookId = await getBookIdFromUrl(page, 'epub');
|
||||||
|
|
||||||
|
// Open the audiobook export modal from the header button
|
||||||
|
await openExportModal(page);
|
||||||
|
|
||||||
|
// Set container format to MP3
|
||||||
|
await setContainerFormatToMP3(page);
|
||||||
|
|
||||||
|
// Start generation
|
||||||
|
await startGeneration(page);
|
||||||
|
|
||||||
|
// Progress card should appear with a Cancel button while chapters are being generated
|
||||||
|
const cancelButton = page.getByRole('button', { name: 'Cancel' });
|
||||||
|
await expect(cancelButton).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Wait until at least 3 chapters are listed in the UI; record the exact count at the
|
||||||
|
// moment we decide to cancel, and assert that no additional chapters are added afterward.
|
||||||
|
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||||
|
await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 });
|
||||||
|
const chapterCountBeforeCancel = await chapterActionsButtons.count();
|
||||||
|
expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
// Now cancel the in-flight generation
|
||||||
|
await cancelButton.click();
|
||||||
|
|
||||||
|
// After cancellation, the inline progress card's Cancel button should be gone
|
||||||
|
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// After cancellation, determine the canonical chapter count from the backend and
|
||||||
|
// assert that the UI eventually reflects this count. Some in-flight chapters may
|
||||||
|
// complete right as we cancel, so we treat the backend state as source of truth.
|
||||||
|
const jsonAfterCancel = await expectChaptersBackendState(page, bookId);
|
||||||
|
expect(jsonAfterCancel.exists).toBe(true);
|
||||||
|
expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true);
|
||||||
|
const chapterCountAfterCancel = jsonAfterCancel.chapters.length;
|
||||||
|
expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel);
|
||||||
|
|
||||||
|
// Wait for the UI to reflect the final backend chapter count to avoid race
|
||||||
|
// conditions between the modal's soft refresh and our assertions.
|
||||||
|
await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 });
|
||||||
|
|
||||||
|
// The Full Download button should still be available for the partially generated audiobook
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
|
expect(durationSeconds).toBeGreaterThan(25);
|
||||||
|
expect(durationSeconds).toBeLessThan(300);
|
||||||
|
|
||||||
|
// Backend should still reflect the same number of chapters as when we first
|
||||||
|
// observed the stabilized post-cancellation state, and should not contain
|
||||||
|
// additional "impartial" chapters produced after cancellation.
|
||||||
|
const json = await expectChaptersBackendState(page, bookId);
|
||||||
|
expect(json.exists).toBe(true);
|
||||||
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
|
expect(json.chapters.length).toBe(chapterCountAfterCancel);
|
||||||
|
|
||||||
|
await resetAudiobookIfPresent(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloads a single chapter via chapter actions menu (PDF)', async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
|
||||||
|
await openExportModal(page);
|
||||||
|
await setContainerFormatToMP3(page);
|
||||||
|
await startGeneration(page);
|
||||||
|
|
||||||
|
await waitForChaptersHeading(page);
|
||||||
|
|
||||||
|
// Wait for at least one chapter row to appear (one "Chapter actions" button)
|
||||||
|
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||||
|
await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 });
|
||||||
|
|
||||||
|
// Download via frontend button
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
|
// For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter.
|
||||||
|
expect(durationSeconds).toBeGreaterThan(9);
|
||||||
|
expect(durationSeconds).toBeLessThan(300);
|
||||||
|
|
||||||
|
await resetAudiobookIfPresent(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset removes all generated chapters for a PDF audiobook', async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
|
||||||
|
await openExportModal(page);
|
||||||
|
await setContainerFormatToMP3(page);
|
||||||
|
await startGeneration(page);
|
||||||
|
|
||||||
|
await waitForChaptersHeading(page);
|
||||||
|
|
||||||
|
// Wait for Reset button to become visible, indicating resumable/generated state
|
||||||
|
const resetButton = page.getByRole('button', { name: 'Reset' });
|
||||||
|
await expect(resetButton).toBeVisible({ timeout: 120_000 });
|
||||||
|
|
||||||
|
await resetButton.click();
|
||||||
|
|
||||||
|
// Confirm in the Reset Audiobook dialog
|
||||||
|
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 });
|
||||||
|
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
|
||||||
|
await confirmReset.click();
|
||||||
|
|
||||||
|
// After reset, the hint text for starting generation should re-appear
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Click "Start Generation" to begin creating your audiobook/i)
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Backend should report no existing chapters for this bookId
|
||||||
|
const res = await page.request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.exists).toBe(false);
|
||||||
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
|
expect(json.chapters.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regenerates a PDF audiobook chapter and preserves chapter count and full download', async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
|
// Extract bookId from /pdf/[id] URL (for backend verification later)
|
||||||
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
|
||||||
|
// Open Export Audiobook modal
|
||||||
|
await openExportModal(page);
|
||||||
|
|
||||||
|
// Set container format to MP3
|
||||||
|
await setContainerFormatToMP3(page);
|
||||||
|
|
||||||
|
// Start generation
|
||||||
|
await startGeneration(page);
|
||||||
|
|
||||||
|
// Wait for chapters to appear
|
||||||
|
await waitForChaptersHeading(page);
|
||||||
|
|
||||||
|
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||||
|
// Ensure we have at least two chapters for this PDF
|
||||||
|
await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 });
|
||||||
|
const chapterCountBefore = await chapterActionsButtons.count();
|
||||||
|
expect(chapterCountBefore).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Open the actions menu for the first chapter and trigger Regenerate
|
||||||
|
const firstChapterActions = chapterActionsButtons.first();
|
||||||
|
await firstChapterActions.click();
|
||||||
|
|
||||||
|
// In the headlessui Menu, each option is a menuitem. Use that role instead of button.
|
||||||
|
const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i });
|
||||||
|
await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 });
|
||||||
|
await regenerateMenuItem.click();
|
||||||
|
|
||||||
|
// During regeneration, the row may show a "Regenerating" label; wait for any such
|
||||||
|
// indicator to disappear, signaling completion.
|
||||||
|
const regeneratingLabel = page.getByText(/Regenerating/);
|
||||||
|
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
|
||||||
|
|
||||||
|
// After regeneration completes in the UI, verify backend chapter state is fully updated
|
||||||
|
// before triggering a full download to avoid races with ffmpeg concat on Alpine.
|
||||||
|
const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId);
|
||||||
|
expect(backendStateAfterRegenerate.exists).toBe(true);
|
||||||
|
expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true);
|
||||||
|
expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore);
|
||||||
|
for (const ch of backendStateAfterRegenerate.chapters) {
|
||||||
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapter count should remain exactly the same after regeneration (no duplicates)
|
||||||
|
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
|
||||||
|
|
||||||
|
// Full Download should still work and produce a valid combined audiobook
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
|
// With two mocked 10s chapters we expect roughly 20s; allow a small window.
|
||||||
|
expect(durationSeconds).toBeGreaterThan(18);
|
||||||
|
expect(durationSeconds).toBeLessThan(22);
|
||||||
|
|
||||||
|
// Backend should still report the same number of chapters and valid durations
|
||||||
|
const json = await expectChaptersBackendState(page, bookId);
|
||||||
|
expect(json.exists).toBe(true);
|
||||||
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
|
expect(json.chapters.length).toBe(chapterCountBefore);
|
||||||
|
for (const ch of json.chapters) {
|
||||||
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetAudiobookIfPresent(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
tests/files/sample.md
Normal file
20
tests/files/sample.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Sample Markdown
|
||||||
|
|
||||||
|
This is a test document with basic Markdown elements.
|
||||||
|
|
||||||
|
## Section One
|
||||||
|
|
||||||
|
- Item 1
|
||||||
|
- Item 2
|
||||||
|
|
||||||
|
Visit [OpenAI](https://www.openai.com) for more information.
|
||||||
|
|
||||||
|
### Subsection
|
||||||
|
|
||||||
|
Bold text: **strong**; Italic text: _emphasis_.
|
||||||
|
|
||||||
|
Code:
|
||||||
|
|
||||||
|
```js
|
||||||
|
console.log('hello markdown');
|
||||||
|
```
|
||||||
BIN
tests/files/sample.mp3
Normal file
BIN
tests/files/sample.mp3
Normal file
Binary file not shown.
Binary file not shown.
1
tests/files/unsupported.xyz
Normal file
1
tests/files/unsupported.xyz
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
This is an unsupported file type used for upload rejection tests.
|
||||||
99
tests/folders.spec.ts
Normal file
99
tests/folders.spec.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { setupTest, uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Document folders and hint persistence', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility to get the draggable row for a given filename (by link)
|
||||||
|
const rowFor = (page: any, fileName: string) => {
|
||||||
|
const link = page.getByRole('link', { name: new RegExp(fileName, 'i') }).first();
|
||||||
|
// The draggable attribute lives on the row container ancestor
|
||||||
|
return link.locator('xpath=ancestor::*[@draggable="true"][1]');
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Folder creation via drag-and-drop with persistence', async ({ page }) => {
|
||||||
|
// Upload three docs
|
||||||
|
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
||||||
|
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
||||||
|
|
||||||
|
// Drag PDF onto EPUB to create a folder
|
||||||
|
const pdfRow = rowFor(page, 'sample.pdf');
|
||||||
|
const epubRow = rowFor(page, 'sample.epub');
|
||||||
|
await pdfRow.dragTo(epubRow);
|
||||||
|
|
||||||
|
// Folder name dialog appears
|
||||||
|
await expect(page.getByRole('heading', { name: 'Create New Folder' })).toBeVisible();
|
||||||
|
const nameInput = page.getByPlaceholder('Enter folder name');
|
||||||
|
await nameInput.fill('My Folder');
|
||||||
|
await nameInput.press('Enter');
|
||||||
|
|
||||||
|
// Folder shows with both docs
|
||||||
|
const folderHeading = page.getByRole('heading', { name: 'My Folder' });
|
||||||
|
await expect(folderHeading).toBeVisible();
|
||||||
|
|
||||||
|
// Scope checks inside the folder container
|
||||||
|
const folderContainer = folderHeading.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]');
|
||||||
|
await expect(folderContainer.getByRole('link', { name: /sample\.pdf/i })).toBeVisible();
|
||||||
|
await expect(folderContainer.getByRole('link', { name: /sample\.epub/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Drag third doc (TXT) into folder
|
||||||
|
const txtRow = rowFor(page, 'sample.txt');
|
||||||
|
await txtRow.dragTo(folderContainer);
|
||||||
|
await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Collapse folder and verify items are hidden
|
||||||
|
const collapseBtn = folderContainer.getByRole('button', { name: 'Collapse folder' });
|
||||||
|
await collapseBtn.scrollIntoViewIfNeeded();
|
||||||
|
await expect(collapseBtn).toBeVisible();
|
||||||
|
await collapseBtn.click();
|
||||||
|
await expect(folderContainer.getByRole('button', { name: 'Expand folder' })).toBeVisible();
|
||||||
|
await expect(folderContainer.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0);
|
||||||
|
await expect(folderContainer.getByRole('link', { name: /sample\.epub/i })).toHaveCount(0);
|
||||||
|
await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Reload and verify persisted folder with collapsed state and documents
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const folderHeadingAfter = page.getByRole('heading', { name: 'My Folder' });
|
||||||
|
await expect(folderHeadingAfter).toBeVisible();
|
||||||
|
const folderContainerAfter = folderHeadingAfter.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]');
|
||||||
|
|
||||||
|
// Still collapsed after reload
|
||||||
|
await expect(folderContainerAfter.getByRole('button', { name: 'Expand folder' })).toBeVisible();
|
||||||
|
await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0);
|
||||||
|
await expect(folderContainerAfter.getByRole('link', { name: /sample\.epub/i })).toHaveCount(0);
|
||||||
|
await expect(folderContainerAfter.getByRole('link', { name: /sample\.txt/i })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Expand and verify all three documents visible
|
||||||
|
const expandBtn = folderContainerAfter.getByRole('button', { name: 'Expand folder' });
|
||||||
|
await expandBtn.scrollIntoViewIfNeeded();
|
||||||
|
await expect(expandBtn).toBeVisible();
|
||||||
|
await expandBtn.click();
|
||||||
|
await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toBeVisible();
|
||||||
|
await expect(folderContainerAfter.getByRole('link', { name: /sample\.epub/i })).toBeVisible();
|
||||||
|
await expect(folderContainerAfter.getByRole('link', { name: /sample\.txt/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dismiss “Drag files to make folders” hint persists after reload', async ({ page }) => {
|
||||||
|
// Need at least 2 docs for the hint to appear
|
||||||
|
await uploadFiles(page, 'sample.pdf', 'sample.epub');
|
||||||
|
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']);
|
||||||
|
|
||||||
|
const hint = page.getByText('Drag files on top of each other to make folders');
|
||||||
|
await expect(hint).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Dismiss hint' }).click();
|
||||||
|
|
||||||
|
// Hint should disappear
|
||||||
|
await expect(hint).toHaveCount(0);
|
||||||
|
|
||||||
|
// Ensure the dismissal has been persisted to IndexedDB before reloading
|
||||||
|
await waitForDocumentListHintPersist(page, false);
|
||||||
|
|
||||||
|
// Reload and ensure it remains dismissed
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.getByText('Drag files on top of each other to make folders')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
343
tests/helpers.ts
343
tests/helpers.ts
|
|
@ -1,6 +1,34 @@
|
||||||
import { Page, expect } from '@playwright/test';
|
import { Page, expect } from '@playwright/test';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
const DIR = './tests/files/';
|
const DIR = './tests/files/';
|
||||||
|
const TTS_MOCK_PATH = path.join(__dirname, 'files', 'sample.mp3');
|
||||||
|
let ttsMockBuffer: Buffer | null = null;
|
||||||
|
|
||||||
|
async function ensureTtsRouteMock(page: Page) {
|
||||||
|
if (!ttsMockBuffer) {
|
||||||
|
ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.route('**/api/tts', async (route) => {
|
||||||
|
// Only mock the POST TTS generation calls; let anything else pass through.
|
||||||
|
if (route.request().method().toUpperCase() !== 'POST') {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'audio/mpeg',
|
||||||
|
body: ttsMockBuffer as Buffer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small util to safely use filenames inside regex patterns
|
||||||
|
function escapeRegExp(input: string) {
|
||||||
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a sample epub or pdf
|
* Upload a sample epub or pdf
|
||||||
|
|
@ -14,19 +42,26 @@ export async function uploadFile(page: Page, filePath: string) {
|
||||||
* Upload and display a document
|
* Upload and display a document
|
||||||
*/
|
*/
|
||||||
export async function uploadAndDisplay(page: Page, fileName: string) {
|
export async function uploadAndDisplay(page: Page, fileName: string) {
|
||||||
// Upload the file
|
|
||||||
await uploadFile(page, fileName);
|
await uploadFile(page, fileName);
|
||||||
|
|
||||||
// Wait for link with document-link class
|
|
||||||
const size = fileName.endsWith('.pdf') ? '0.02 MB' : '0.33 MB';
|
|
||||||
await page.getByRole('link', { name: `${fileName} ${size}` }).click();
|
|
||||||
|
|
||||||
// Wait for the document to load
|
const lower = fileName.toLowerCase();
|
||||||
if (fileName.endsWith('.pdf')) {
|
|
||||||
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
|
if (lower.endsWith('.docx')) {
|
||||||
|
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible();
|
||||||
|
const pdfName = fileName.replace(/\.docx$/i, '.pdf');
|
||||||
|
await page.getByRole('link', { name: new RegExp(escapeRegExp(pdfName), 'i') }).click();
|
||||||
|
await page.waitForSelector('.react-pdf__Document', { timeout: 15000 });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else if (fileName.endsWith('.epub')) {
|
|
||||||
|
await page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).click();
|
||||||
|
|
||||||
|
if (lower.endsWith('.pdf')) {
|
||||||
|
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
|
||||||
|
} else if (lower.endsWith('.epub')) {
|
||||||
await page.waitForSelector('.epub-container', { timeout: 10000 });
|
await page.waitForSelector('.epub-container', { timeout: 10000 });
|
||||||
|
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
||||||
|
await page.waitForSelector('.html-container', { timeout: 10000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,20 +73,8 @@ export async function waitAndClickPlay(page: Page) {
|
||||||
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible();
|
||||||
// Play the TTS by clicking the button
|
// Play the TTS by clicking the button
|
||||||
await page.getByRole('button', { name: 'Play' }).click();
|
await page.getByRole('button', { name: 'Play' }).click();
|
||||||
|
// Use resilient processing transition helper (tolerates fast completion)
|
||||||
// Expect for buttons to be disabled
|
await expectProcessingTransition(page);
|
||||||
await expect(page.locator('button[aria-label="Skip forward"][disabled]')).toBeVisible();
|
|
||||||
await expect(page.locator('button[aria-label="Skip backward"][disabled]')).toBeVisible();
|
|
||||||
|
|
||||||
// Wait for the TTS to stop processing
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForSelector('button[aria-label="Skip forward"]:not([disabled])', { timeout: 45000 }),
|
|
||||||
page.waitForSelector('button[aria-label="Skip backward"]:not([disabled])', { timeout: 45000 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
return navigator.mediaSession?.playbackState === 'playing';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -65,19 +88,285 @@ export async function playTTSAndWaitForASecond(page: Page, fileName: string) {
|
||||||
// play for 1s
|
// play for 1s
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Pause TTS playback and verify paused state
|
||||||
|
*/
|
||||||
|
export async function pauseTTSAndVerify(page: Page) {
|
||||||
|
// Click pause to stop playback
|
||||||
|
await page.getByRole('button', { name: 'Pause' }).click();
|
||||||
|
|
||||||
|
// Check for play button to be visible
|
||||||
|
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common test setup function
|
* Common test setup function
|
||||||
*/
|
*/
|
||||||
export async function setupTest(page: Page) {
|
export async function setupTest(page: Page) {
|
||||||
|
// Mock the TTS API so tests don't hit the real TTS service.
|
||||||
|
await ensureTtsRouteMock(page);
|
||||||
|
|
||||||
// Navigate to the home page before each test
|
// Navigate to the home page before each test
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click();
|
// If running in CI, select the "Custom OpenAI-Like" model and "Deepinfra" provider
|
||||||
await page.getByText('Deepinfra').click();
|
if (process.env.CI) {
|
||||||
|
await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click();
|
||||||
|
await page.getByText('Deepinfra').click();
|
||||||
|
}
|
||||||
|
|
||||||
// Click the "done" button to dismiss the welcome message
|
// Click the "done" button to dismiss the welcome message
|
||||||
await page.getByRole('tab', { name: '🔑 API' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Assert a document link containing the given filename appears in the list
|
||||||
|
export async function expectDocumentListed(page: Page, fileName: string) {
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
||||||
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert a document link containing the given filename does NOT exist
|
||||||
|
export async function expectNoDocumentLink(page: Page, fileName: string) {
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
||||||
|
).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload multiple files in sequence
|
||||||
|
export async function uploadFiles(page: Page, ...fileNames: string[]) {
|
||||||
|
for (const name of fileNames) {
|
||||||
|
await uploadFile(page, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a set of documents are visible in the list
|
||||||
|
export async function ensureDocumentsListed(page: Page, fileNames: string[]) {
|
||||||
|
for (const name of fileNames) {
|
||||||
|
await expectDocumentListed(page, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click the document link row by filename
|
||||||
|
export async function clickDocumentLink(page: Page, fileName: string) {
|
||||||
|
await page
|
||||||
|
.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect correct URL and viewer to be visible for a given file by extension
|
||||||
|
export async function expectViewerForFile(page: Page, fileName: string) {
|
||||||
|
const lower = fileName.toLowerCase();
|
||||||
|
if (lower.endsWith('.pdf') || lower.endsWith('.docx')) {
|
||||||
|
// DOCX converts to PDF, so viewer expectations are PDF
|
||||||
|
await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/);
|
||||||
|
await expect(page.locator('.react-pdf__Document')).toBeVisible({ timeout: 15000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lower.endsWith('.epub')) {
|
||||||
|
await expect(page).toHaveURL(/\/epub\/[A-Za-z0-9._%-]+$/);
|
||||||
|
await expect(page.locator('.epub-container')).toBeVisible({ timeout: 15000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
||||||
|
await expect(page).toHaveURL(/\/html\/[A-Za-z0-9._%-]+$/);
|
||||||
|
await expect(page.locator('.html-container')).toBeVisible({ timeout: 15000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a single document by filename via row action and confirm dialog
|
||||||
|
export async function deleteDocumentByName(page: Page, fileName: string) {
|
||||||
|
const link = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
|
||||||
|
await link.locator('xpath=..').getByRole('button', { name: 'Delete document' }).click();
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: 'Delete Document' });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');
|
||||||
|
await confirmBtn.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Settings modal and navigate to Documents tab
|
||||||
|
export async function openSettingsDocumentsTab(page: Page) {
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await page.getByRole('tab', { name: '📄 Documents' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all local documents through Settings and close dialogs
|
||||||
|
export async function deleteAllLocalDocuments(page: Page) {
|
||||||
|
await openSettingsDocumentsTab(page);
|
||||||
|
await page.getByRole('button', { name: 'Delete local docs' }).click();
|
||||||
|
|
||||||
|
const heading = page.getByRole('heading', { name: 'Delete Local Documents' });
|
||||||
|
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');
|
||||||
|
await confirmBtn.click();
|
||||||
|
|
||||||
|
// Close any remaining modal layers
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the Voices dropdown from the TTS bar and return the button locator
|
||||||
|
export async function openVoicesMenu(page: Page) {
|
||||||
|
const ttsbar = page.locator('[data-app-ttsbar]');
|
||||||
|
await expect(ttsbar).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// If the listbox/options already exist, assume it's open and return (idempotent)
|
||||||
|
const alreadyOpen = await page.locator('[role="listbox"], [role="option"]').count();
|
||||||
|
if (alreadyOpen > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer a stable selector using accessible name if present, otherwise fall back to a
|
||||||
|
// button whose label matches any known default voice (including "af_" prefixed ones),
|
||||||
|
// and finally the last button heuristic.
|
||||||
|
const candidateByName = ttsbar.getByRole('button', { name: /Voices|(af_)?(alloy|ash|coral|echo|fable|onyx|nova|sage|shimmer)/i });
|
||||||
|
|
||||||
|
const hasNamed = await candidateByName.count();
|
||||||
|
const voicesButton = hasNamed > 0 ? candidateByName.first() : ttsbar.getByRole('button').last();
|
||||||
|
|
||||||
|
await expect(voicesButton).toBeVisible();
|
||||||
|
await voicesButton.click();
|
||||||
|
|
||||||
|
// Wait for the options panel to appear; tolerate different render strategies by
|
||||||
|
// waiting for either the listbox container or at least one option.
|
||||||
|
await Promise.race([
|
||||||
|
page.waitForSelector('[role="listbox"]', { timeout: 10000 }),
|
||||||
|
page.waitForSelector('[role="option"]', { timeout: 10000 }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a voice from the Voices dropdown and assert processing -> playing
|
||||||
|
export async function selectVoiceAndAssertPlayback(page: Page, voiceName: string | RegExp) {
|
||||||
|
// Ensure the menu is open without toggling it closed if already open
|
||||||
|
const optionCount = await page.locator('[role="option"]').count();
|
||||||
|
if (optionCount === 0) {
|
||||||
|
await openVoicesMenu(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('option', { name: voiceName }).first().click();
|
||||||
|
await expectProcessingTransition(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert skip buttons disabled during processing, then enabled, and playbackState=playing
|
||||||
|
export async function expectProcessingTransition(page: Page) {
|
||||||
|
// Try to detect a brief processing phase where skip buttons are disabled,
|
||||||
|
// but tolerate cases where processing completes too quickly to observe.
|
||||||
|
const disabledForward = page.locator('button[aria-label="Skip forward"][disabled]');
|
||||||
|
const disabledBackward = page.locator('button[aria-label="Skip backward"][disabled]');
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
expect(disabledForward).toBeVisible({ timeout: 3000 }),
|
||||||
|
expect(disabledBackward).toBeVisible({ timeout: 3000 }),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// Processing may have completed before we observed disabled state; cause warning but continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the TTS to stop processing and buttons to be enabled
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForSelector('button[aria-label="Skip forward"]:not([disabled])', { timeout: 45000 }),
|
||||||
|
page.waitForSelector('button[aria-label="Skip backward"]:not([disabled])', { timeout: 45000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ensure media session is playing
|
||||||
|
await expectMediaState(page, 'playing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect navigator.mediaSession.playbackState to equal given state
|
||||||
|
export async function expectMediaState(page: Page, state: 'playing' | 'paused') {
|
||||||
|
// WebKit (and sometimes other engines) may not reliably update navigator.mediaSession.playbackState.
|
||||||
|
// Fallback heuristics:
|
||||||
|
// 1. Prefer mediaSession if it matches desired state.
|
||||||
|
// 2. Otherwise inspect any <audio> element: use paused flag and currentTime progression.
|
||||||
|
// 3. Allow short grace period for first frame to advance.
|
||||||
|
// 4. If neither detectable, keep polling until timeout.
|
||||||
|
await page.waitForFunction((desired) => {
|
||||||
|
try {
|
||||||
|
const msState = (navigator.mediaSession && navigator.mediaSession.playbackState) || '';
|
||||||
|
if (msState === desired) return true;
|
||||||
|
|
||||||
|
const audio: HTMLAudioElement | null = document.querySelector('audio');
|
||||||
|
if (audio) {
|
||||||
|
// Track advancement by storing last time on the element dataset
|
||||||
|
const last = parseFloat(audio.dataset.lastTime || '0');
|
||||||
|
const curr = audio.currentTime;
|
||||||
|
audio.dataset.lastTime = String(curr);
|
||||||
|
|
||||||
|
if (desired === 'playing') {
|
||||||
|
// Consider playing if not paused AND time has advanced at least a tiny amount
|
||||||
|
if (!audio.paused && curr > 0 && curr > last) return true;
|
||||||
|
} else {
|
||||||
|
// paused target
|
||||||
|
if (audio.paused) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Navigator to go to a specific page number (PDF)
|
||||||
|
export async function navigateToPdfPageViaNavigator(page: Page, targetPage: number) {
|
||||||
|
// Navigator popover shows "X / Y"
|
||||||
|
const navTrigger = page.getByRole('button', { name: /\d+\s*\/\s*\d+/ });
|
||||||
|
await expect(navTrigger).toBeVisible({ timeout: 10000 });
|
||||||
|
await navTrigger.click();
|
||||||
|
|
||||||
|
const input = page.getByLabel('Page number');
|
||||||
|
await expect(input).toBeVisible({ timeout: 10000 });
|
||||||
|
await input.fill(String(targetPage));
|
||||||
|
await input.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count currently rendered react-pdf Page components
|
||||||
|
export async function countRenderedPdfPages(page: Page): Promise<number> {
|
||||||
|
return await page.locator('.react-pdf__Page').count();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count currently rendered text layers (active page(s))
|
||||||
|
export async function countRenderedTextLayers(page: Page): Promise<number> {
|
||||||
|
return await page.locator('.react-pdf__Page__textContent').count();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force viewport resize to trigger resize hooks (e.g., EPUB)
|
||||||
|
export async function triggerViewportResize(page: Page, width: number, height: number) {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DocumentListState.showHint to persist in IndexedDB 'app-config' store
|
||||||
|
export async function waitForDocumentListHintPersist(page: Page, expected: boolean) {
|
||||||
|
await page.waitForFunction(async (exp) => {
|
||||||
|
try {
|
||||||
|
const openDb = () => new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const req = indexedDB.open('openreader-db');
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
const db = await openDb();
|
||||||
|
const readConfig = () => new Promise<any>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(['app-config'], 'readonly');
|
||||||
|
const store = tx.objectStore('app-config');
|
||||||
|
const getReq = store.get('singleton');
|
||||||
|
getReq.onsuccess = () => resolve(getReq.result);
|
||||||
|
getReq.onerror = () => reject(getReq.error);
|
||||||
|
});
|
||||||
|
const item = await readConfig();
|
||||||
|
db.close();
|
||||||
|
if (!item || typeof item.documentListState !== 'object') return false;
|
||||||
|
const state = item.documentListState;
|
||||||
|
if (!state || typeof state.showHint !== 'boolean') return false;
|
||||||
|
return state.showHint === exp;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, expected, { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
|
||||||
125
tests/navigation.spec.ts
Normal file
125
tests/navigation.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
setupTest,
|
||||||
|
uploadFiles,
|
||||||
|
ensureDocumentsListed,
|
||||||
|
clickDocumentLink,
|
||||||
|
expectViewerForFile,
|
||||||
|
uploadAndDisplay,
|
||||||
|
playTTSAndWaitForASecond,
|
||||||
|
expectProcessingTransition,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
// Single-spec helpers kept local to avoid cluttering shared helpers:
|
||||||
|
async function navigateToPdfPageViaNavigator(page: any, targetPage: number) {
|
||||||
|
// Navigator popover shows "X / Y"
|
||||||
|
const navTrigger = page.getByRole('button', { name: /\d+\s*\/\s*\d+/ });
|
||||||
|
await expect(navTrigger).toBeVisible({ timeout: 10000 });
|
||||||
|
await navTrigger.click();
|
||||||
|
|
||||||
|
const input = page.getByLabel('Page number');
|
||||||
|
await expect(input).toBeVisible({ timeout: 10000 });
|
||||||
|
await input.fill(String(targetPage));
|
||||||
|
await input.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countRenderedPdfPages(page: any): Promise<number> {
|
||||||
|
return await page.locator('.react-pdf__Page').count();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerViewportResize(page: any, width: number, height: number) {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Document link navigation by type', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigates to /pdf, /epub, /html and renders correct viewers', async ({ page }) => {
|
||||||
|
// Upload documents
|
||||||
|
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
||||||
|
|
||||||
|
// Ensure links exist
|
||||||
|
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
await clickDocumentLink(page, 'sample.pdf');
|
||||||
|
await expectViewerForFile(page, 'sample.pdf');
|
||||||
|
await page.goBack();
|
||||||
|
|
||||||
|
// EPUB
|
||||||
|
await clickDocumentLink(page, 'sample.epub');
|
||||||
|
await expectViewerForFile(page, 'sample.epub');
|
||||||
|
await page.goBack();
|
||||||
|
|
||||||
|
// TXT (HTML viewer)
|
||||||
|
await clickDocumentLink(page, 'sample.txt');
|
||||||
|
await expectViewerForFile(page, 'sample.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PDF view modes and Navigator', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches Single/Dual/Scroll modes and uses Navigator to change page', async ({ page }) => {
|
||||||
|
// Open PDF viewer
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
await expect(page.locator('.react-pdf__Document')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Open document settings (page-level settings)
|
||||||
|
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||||
|
|
||||||
|
// The mode Listbox initially shows "Single Page" by default; switch to "Two Pages"
|
||||||
|
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click();
|
||||||
|
await page.getByRole('option', { name: 'Two Pages' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
// Expect dual-page rendering (sample.pdf has >= 2 pages)
|
||||||
|
const dualCount = await countRenderedPdfPages(page);
|
||||||
|
expect(dualCount).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Switch to Continuous Scroll
|
||||||
|
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||||
|
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click();
|
||||||
|
await page.getByRole('option', { name: 'Continuous Scroll' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
// Expect continuous scroll renders at least as many pages as dual mode
|
||||||
|
const scrollCount = await countRenderedPdfPages(page);
|
||||||
|
expect(scrollCount).toBeGreaterThanOrEqual(dualCount);
|
||||||
|
|
||||||
|
// Use Navigator to go to a page (clamps to last if too large)
|
||||||
|
await navigateToPdfPageViaNavigator(page, 999);
|
||||||
|
// Navigator jump is configured to pause; ensure Play is visible then resume
|
||||||
|
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||||
|
await page.getByRole('button', { name: 'Play' }).click();
|
||||||
|
await expectProcessingTransition(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('EPUB resize pauses TTS', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resizing viewport pauses playback and play resumes after', async ({ page }) => {
|
||||||
|
// Start playback on EPUB
|
||||||
|
await playTTSAndWaitForASecond(page, 'sample.epub');
|
||||||
|
|
||||||
|
// Trigger a significant viewport resize to fire useEPUBResize
|
||||||
|
await triggerViewportResize(page, 1200, 900);
|
||||||
|
await page.waitForTimeout(750); // allow resize flag to propagate
|
||||||
|
await triggerViewportResize(page, 900, 700);
|
||||||
|
await page.waitForTimeout(750);
|
||||||
|
|
||||||
|
// After resize, playback should have paused (Play button visible)
|
||||||
|
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Resume playback and ensure processing -> playing
|
||||||
|
await page.getByRole('button', { name: 'Play' }).click();
|
||||||
|
await expectProcessingTransition(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,32 +1,131 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { setupTest, playTTSAndWaitForASecond } from './helpers';
|
import {
|
||||||
|
setupTest,
|
||||||
|
playTTSAndWaitForASecond,
|
||||||
|
openVoicesMenu,
|
||||||
|
selectVoiceAndAssertPlayback,
|
||||||
|
expectMediaState,
|
||||||
|
expectProcessingTransition,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
// Single-spec helpers kept local instead of living in shared helpers:
|
||||||
|
async function pauseTTSAndVerify(page: any) {
|
||||||
|
// Click pause to stop playback
|
||||||
|
await page.getByRole('button', { name: 'Pause' }).click();
|
||||||
|
// Check for play button to be visible
|
||||||
|
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSpeedPopover(page: any) {
|
||||||
|
const ttsbar = page.locator('[data-app-ttsbar]');
|
||||||
|
const buttons = ttsbar.getByRole('button');
|
||||||
|
// Heuristic: the Speed control is the first button in the TTS bar and shows something like "1x"
|
||||||
|
const speedBtn = buttons.first();
|
||||||
|
await expect(speedBtn).toBeVisible({ timeout: 10000 });
|
||||||
|
await speedBtn.click();
|
||||||
|
// Popover panel should appear with sliders
|
||||||
|
await page.waitForSelector('input[type="range"]', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeNativeSpeedAndAssert(page: any, newSpeed: number) {
|
||||||
|
await openSpeedPopover(page);
|
||||||
|
const slider = page.locator('input[type="range"]').first();
|
||||||
|
|
||||||
|
// Set the slider value programmatically and dispatch events to trigger handlers
|
||||||
|
const valueStr = String(newSpeed);
|
||||||
|
await slider.evaluate((input: HTMLInputElement, v: string) => {
|
||||||
|
input.value = v;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event('mouseup', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'ArrowRight' }));
|
||||||
|
input.dispatchEvent(new Event('touchend', { bubbles: true }));
|
||||||
|
}, valueStr);
|
||||||
|
|
||||||
|
await expectProcessingTransition(page);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Play/Pause Tests', () => {
|
test.describe('Play/Pause Tests', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupTest(page);
|
await setupTest(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial', timeout: 60000 });
|
||||||
|
|
||||||
test('plays and pauses TTS for a PDF document', async ({ page }) => {
|
test('plays and pauses TTS for a PDF document', async ({ page }) => {
|
||||||
// Play TTS for the PDF document
|
// Play TTS for the PDF document
|
||||||
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
||||||
|
|
||||||
// Click pause to stop playback
|
// Pause TTS and verify paused state
|
||||||
await page.getByRole('button', { name: 'Pause' }).click();
|
await pauseTTSAndVerify(page);
|
||||||
|
|
||||||
// Check for play button to be visible
|
|
||||||
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plays and pauses TTS for an EPUB document', async ({ page }) => {
|
test('plays and pauses TTS for an EPUB document', async ({ page }) => {
|
||||||
// Play TTS for the EPUB document
|
// Play TTS for the EPUB document
|
||||||
await playTTSAndWaitForASecond(page, 'sample.epub');
|
await playTTSAndWaitForASecond(page, 'sample.epub');
|
||||||
|
|
||||||
// Click pause to stop playback
|
// Pause TTS and verify paused state
|
||||||
await page.getByRole('button', { name: 'Pause' }).click();
|
await pauseTTSAndVerify(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plays and pauses TTS for an DOCX document', async ({ page }) => {
|
||||||
|
// Play TTS for the DOCX document
|
||||||
|
await playTTSAndWaitForASecond(page, 'sample.docx');
|
||||||
|
|
||||||
// Check for play button to be visible
|
// Pause TTS and verify paused state
|
||||||
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
await pauseTTSAndVerify(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plays and pauses TTS for a TXT document', async ({ page }) => {
|
||||||
|
// Play TTS for the TXT document
|
||||||
|
await playTTSAndWaitForASecond(page, 'sample.txt');
|
||||||
|
|
||||||
|
// Pause TTS and verify paused state
|
||||||
|
await pauseTTSAndVerify(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches to a single voice and resumes playing', async ({ page }) => {
|
||||||
|
// Start playback
|
||||||
|
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
||||||
|
|
||||||
|
// Ensure basic TTS controls are present
|
||||||
|
await expect(page.getByRole('button', { name: 'Skip backward' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Skip forward' })).toBeVisible();
|
||||||
|
|
||||||
|
// Open voices list and assert options render
|
||||||
|
await openVoicesMenu(page);
|
||||||
|
const options = page.getByRole('option');
|
||||||
|
expect(await options.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await selectVoiceAndAssertPlayback(page, 'af_bella');
|
||||||
|
//await expectProcessingTransition(page);
|
||||||
|
|
||||||
|
// Final state should be playing
|
||||||
|
await expectMediaState(page, 'playing');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!process.env.CI) test('selects multiple Kokoro voices and resumes playing', async ({ page }) => {
|
||||||
|
// Start playback
|
||||||
|
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
||||||
|
|
||||||
|
// Ensure TTS controls are present
|
||||||
|
await expect(page.getByRole('button', { name: 'Skip backward' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Skip forward' })).toBeVisible();
|
||||||
|
|
||||||
|
// Select first voice (e.g., bf_emma) and assert processing -> playing
|
||||||
|
await openVoicesMenu(page);
|
||||||
|
await selectVoiceAndAssertPlayback(page, 'bf_emma');
|
||||||
|
|
||||||
|
// Select second voice (e.g., af_heart) to create a multi-voice mix and assert again
|
||||||
|
await openVoicesMenu(page);
|
||||||
|
await selectVoiceAndAssertPlayback(page, 'af_heart');
|
||||||
|
|
||||||
|
// Final state should be playing
|
||||||
|
await expectMediaState(page, 'playing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing TTS native speed toggles processing and returns to playing', async ({ page }) => {
|
||||||
|
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
||||||
|
await changeNativeSpeedAndAssert(page, 1.5);
|
||||||
|
await expectMediaState(page, 'playing');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { uploadFile, uploadAndDisplay, setupTest } from './helpers';
|
import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers';
|
||||||
|
|
||||||
test.describe('Document Upload Tests', () => {
|
test.describe('Document Upload Tests', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
|
@ -8,12 +8,17 @@ test.describe('Document Upload Tests', () => {
|
||||||
|
|
||||||
test('uploads a PDF document', async ({ page }) => {
|
test('uploads a PDF document', async ({ page }) => {
|
||||||
await uploadFile(page, 'sample.pdf');
|
await uploadFile(page, 'sample.pdf');
|
||||||
await expect(page.getByText('sample.pdf')).toBeVisible({ timeout: 10000 });
|
await expectDocumentListed(page, 'sample.pdf');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uploads an EPUB document', async ({ page }) => {
|
test('uploads an EPUB document', async ({ page }) => {
|
||||||
await uploadFile(page, 'sample.epub');
|
await uploadFile(page, 'sample.epub');
|
||||||
await expect(page.getByText('sample.epub')).toBeVisible({ timeout: 10000 });
|
await expectDocumentListed(page, 'sample.epub');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uploads a TXT document', async ({ page }) => {
|
||||||
|
await uploadFile(page, 'sample.txt');
|
||||||
|
await expectDocumentListed(page, 'sample.txt');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uploads and converts a DOCX document', async ({ page }) => {
|
test('uploads and converts a DOCX document', async ({ page }) => {
|
||||||
|
|
@ -24,20 +29,101 @@ test.describe('Document Upload Tests', () => {
|
||||||
// Should see the converting message
|
// Should see the converting message
|
||||||
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible();
|
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible();
|
||||||
// After conversion, should see the PDF with the same name
|
// After conversion, should see the PDF with the same name
|
||||||
await expect(page.getByText('sample.pdf')).toBeVisible({ timeout: 15000 });
|
await expectDocumentListed(page, 'sample.pdf');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('displays a PDF document', async ({ page }) => {
|
test('displays a PDF document', async ({ page }) => {
|
||||||
await uploadAndDisplay(page, 'sample.pdf');
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
await expect(page.locator('.react-pdf__Document')).toBeVisible();
|
await expectViewerForFile(page, 'sample.pdf');
|
||||||
|
// Additional content checks specific to the sample PDF
|
||||||
await expect(page.locator('.react-pdf__Page')).toBeVisible();
|
await expect(page.locator('.react-pdf__Page')).toBeVisible();
|
||||||
await expect(page.getByText('Sample PDF')).toBeVisible();
|
await expect(page.getByText('Sample PDF')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('displays an EPUB document', async ({ page }) => {
|
test('displays an EPUB document', async ({ page }) => {
|
||||||
await uploadAndDisplay(page, 'sample.epub');
|
await uploadAndDisplay(page, 'sample.epub');
|
||||||
await expect(page.locator('.epub-container')).toBeVisible({ timeout: 10000 });
|
await expectViewerForFile(page, 'sample.epub');
|
||||||
|
// Keep navigation button assertions
|
||||||
await expect(page.getByRole('button', { name: '‹' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '‹' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '›' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '›' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('displays a DOCX document as PDF after conversion', async ({ page }) => {
|
||||||
|
await uploadAndDisplay(page, 'sample.docx');
|
||||||
|
await expectViewerForFile(page, 'sample.docx'); // DOCX converts to PDF
|
||||||
|
// Keep specific content checks
|
||||||
|
await expect(page.locator('.react-pdf__Page')).toBeVisible();
|
||||||
|
await expect(page.getByText('Demonstration of DOCX')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displays a TXT document', async ({ page }) => {
|
||||||
|
await uploadAndDisplay(page, 'sample.txt');
|
||||||
|
await expectViewerForFile(page, 'sample.txt');
|
||||||
|
await expect(page.getByText('Lorem ipsum dolor sit amet')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uploads PDF/EPUB/TXT and opens correct viewer for each', async ({ page }) => {
|
||||||
|
// Upload multiple files
|
||||||
|
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
||||||
|
|
||||||
|
// Verify all uploaded files appear in the list
|
||||||
|
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
||||||
|
|
||||||
|
// PDF navigation and viewer
|
||||||
|
await clickDocumentLink(page, 'sample.pdf');
|
||||||
|
await expectViewerForFile(page, 'sample.pdf');
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.getByText('Local Documents')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// EPUB navigation and viewer
|
||||||
|
await clickDocumentLink(page, 'sample.epub');
|
||||||
|
await expectViewerForFile(page, 'sample.epub');
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.getByText('Local Documents')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// TXT navigation and viewer (HTML viewer)
|
||||||
|
await clickDocumentLink(page, 'sample.txt');
|
||||||
|
await expectViewerForFile(page, 'sample.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders Markdown via ReactMarkdown and keeps TXT preformatted', async ({ page }) => {
|
||||||
|
// Upload MD and TXT
|
||||||
|
await uploadFiles(page, 'sample.md', 'sample.txt');
|
||||||
|
await ensureDocumentsListed(page, ['sample.md', 'sample.txt']);
|
||||||
|
|
||||||
|
// Open MD and verify rendered markdown
|
||||||
|
await clickDocumentLink(page, 'sample.md');
|
||||||
|
await expectViewerForFile(page, 'sample.md');
|
||||||
|
const mdContainer = page.locator('.html-container');
|
||||||
|
await expect(mdContainer).toBeVisible();
|
||||||
|
// Should have prose classes (not monospace)
|
||||||
|
await expect(mdContainer).toHaveClass(/prose/);
|
||||||
|
await expect(mdContainer).not.toHaveClass(/font-mono/);
|
||||||
|
// Heading and link rendered
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sample Markdown' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'OpenAI' })).toBeVisible();
|
||||||
|
|
||||||
|
// Go back and open TXT, verify monospace preformatted
|
||||||
|
await page.goBack();
|
||||||
|
await clickDocumentLink(page, 'sample.txt');
|
||||||
|
await expectViewerForFile(page, 'sample.txt');
|
||||||
|
const txtContainer = page.locator('.html-container');
|
||||||
|
await expect(txtContainer).toHaveClass(/font-mono/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsupported file type is ignored and no new document is created', async ({ page }) => {
|
||||||
|
// Capture initial list of names
|
||||||
|
const before = await page.locator('.document-link').count();
|
||||||
|
|
||||||
|
// Try to upload unsupported file
|
||||||
|
await uploadFile(page, 'unsupported.xyz');
|
||||||
|
// Give the UI a moment just in case
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Assert no new document entries created
|
||||||
|
const after = await page.locator('.document-link').count();
|
||||||
|
expect(after).toBe(before);
|
||||||
|
// Also ensure no link with that filename exists
|
||||||
|
await expect(page.getByRole('link', { name: /unsupported\.xyz/i })).toHaveCount(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Loading…
Reference in a new issue