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
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
branches: [ main, master, version1.0.0 ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
|
|
@ -16,12 +16,14 @@ jobs:
|
|||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- name: Install Deps (FFmpeg is install through Playwright)
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libreoffice-writer
|
||||
sudo apt-get install -y libreoffice-writer ffmpeg
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Verify ffprobe
|
||||
run: ffprobe -version
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ COPY . .
|
|||
|
||||
# Build the Next.js application
|
||||
RUN pnpm exec next telemetry disable
|
||||
RUN pnpm run build
|
||||
RUN pnpm build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3003
|
||||
|
|
|
|||
189
README.md
189
README.md
|
|
@ -7,28 +7,69 @@
|
|||
|
||||
[](../../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**:
|
||||
- **OpenAI**: tts-1, tts-1-hd, gpt-4o-mini-tts models with voices (alloy, echo, fable, onyx, nova, shimmer)
|
||||
- 🧠 **(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
|
||||
- 🎧 **(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
|
||||
- **Custom OpenAI-Compatible**: Any OpenAI-compatible endpoint with custom voice sets
|
||||
- 💾 **Local-First Architecture**: Uses IndexedDB browser storage for documents
|
||||
- 🛜 **Optional Server-side documents**: Manually upload documents to the next backend for all users to download
|
||||
- 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates
|
||||
- 📄 **Document formats**: EPUB, PDF, TXT, MD, DOCX (with libreoffice installed)
|
||||
- 🎧 **Audiobook Creation**: Create and export audiobooks from PDF and ePub files **(in m4b format with ffmpeg and aac TTS output)**
|
||||
- **OpenAI API ($$)**: tts-1, tts-1-hd, gpt-4o-mini-tts models
|
||||
- **Kokoro-FastAPI**: Self-hosted OpenAI-compatible TTS API server supporting Kokoro-82M and multi-voice combinations (like `af_heart+bf_emma`)
|
||||
- **Orpheus-FastAPI**: Self-hosted OpenAI-compatible TTS API server supporting Orpheus-3B
|
||||
- And other Custom OpenAI-compatible endpoints with a `/v1/audio/voices` endpoint
|
||||
- 🚀 **(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
|
||||
- 💾 **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**:
|
||||
- 🔑 Select TTS provider (OpenAI, Deepinfra, or Custom OpenAI-compatible)
|
||||
- 🔐 Set TTS API base URL and optional API key
|
||||
- 🎨 Multiple app theme options
|
||||
- And more...
|
||||
|
||||
### 🛠️ Work in progress
|
||||
- [ ] **Native .docx support** (currently requires libreoffice)
|
||||
- [ ] **Accessibility Improvements**
|
||||
<details>
|
||||
<summary>
|
||||
|
||||
### 🆕 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
|
||||
|
||||
|
|
@ -36,9 +77,12 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering
|
|||
- Recent version of Docker installed on your machine
|
||||
- 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:
|
||||
```bash
|
||||
docker run --name openreader-webui \
|
||||
--restart unless-stopped \
|
||||
-p 3003:3003 \
|
||||
-v openreader_docstore:/app/docstore \
|
||||
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
|
||||
```bash
|
||||
docker run --name openreader-webui \
|
||||
--restart unless-stopped \
|
||||
-e API_KEY=none \
|
||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||
-p 3003:3003 \
|
||||
|
|
@ -72,48 +117,82 @@ docker rm openreader-webui && \
|
|||
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
|
||||
# Download example docker-compose.yml
|
||||
curl --create-dirs -L -o openreader-compose/docker-compose.yml https://raw.githubusercontent.com/richardr1126/OpenReader-WebUI/main/docs/examples/docker-compose.yml
|
||||
|
||||
cd openreader-compose
|
||||
docker compose up -d
|
||||
docker run -d \
|
||||
--name kokoro-tts \
|
||||
--restart unless-stopped \
|
||||
-p 8880:8880 \
|
||||
-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`:
|
||||
```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
|
||||
</details>
|
||||
|
||||
volumes:
|
||||
docstore:
|
||||
<details>
|
||||
<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
|
||||
- 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:
|
||||
- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only)
|
||||
- On Linux: `sudo apt install ffmpeg`
|
||||
- On MacOS: `brew install ffmpeg`
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
```
|
||||
- [libreoffice](https://www.libreoffice.org) (required for DOCX files)
|
||||
- On Linux: `sudo apt install libreoffice`
|
||||
- On MacOS: `brew install libreoffice`
|
||||
|
||||
```bash
|
||||
brew install libreoffice
|
||||
```
|
||||
### Steps
|
||||
|
||||
1. Clone the repository:
|
||||
|
|
@ -126,12 +205,7 @@ Optionally required for different features:
|
|||
|
||||
With pnpm (recommended):
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
```bash
|
||||
npm install
|
||||
pnpm i # or npm i
|
||||
```
|
||||
|
||||
3. Configure the environment:
|
||||
|
|
@ -145,26 +219,15 @@ Optionally required for different features:
|
|||
|
||||
With pnpm (recommended):
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
```bash
|
||||
npm run dev
|
||||
pnpm dev # or npm run dev
|
||||
```
|
||||
|
||||
or build and run the production server:
|
||||
|
||||
With pnpm:
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Or with npm:
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
pnpm build # or npm run build
|
||||
pnpm start # or npm start
|
||||
```
|
||||
|
||||
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)
|
||||
- **Containerization:** Docker
|
||||
- **Storage:** IndexedDB (in browser db store)
|
||||
- **Storage:** Dexie + IndexedDB (in-browser local database)
|
||||
- **PDF:**
|
||||
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
|
||||
- [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",
|
||||
"version": "0.4.0",
|
||||
"version": "v1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 3003",
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
"cmpstr": "^3.0.4",
|
||||
"compromise": "^14.14.4",
|
||||
"core-js": "^3.46.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"epubjs": "^0.3.93",
|
||||
"howler": "^2.2.4",
|
||||
"lru-cache": "^11.2.2",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
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.
|
||||
*/
|
||||
|
|
@ -15,14 +7,11 @@ export default defineConfig({
|
|||
testDir: './tests',
|
||||
timeout: 30 * 1000,
|
||||
outputDir: './tests/results',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
// fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: undefined,
|
||||
// workers: '50%',
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ importers:
|
|||
core-js:
|
||||
specifier: ^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:
|
||||
specifier: ^0.3.93
|
||||
version: 0.3.93
|
||||
|
|
@ -1093,6 +1099,16 @@ packages:
|
|||
devlop@1.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
|
|
@ -3710,6 +3726,14 @@ snapshots:
|
|||
dependencies:
|
||||
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: {}
|
||||
|
||||
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 { 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 { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
|
@ -9,9 +9,11 @@ interface ConversionRequest {
|
|||
chapterTitle: string;
|
||||
buffer: number[];
|
||||
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) => {
|
||||
const ffprobe = spawn('ffprobe', [
|
||||
'-i', filePath,
|
||||
|
|
@ -21,11 +23,38 @@ async function getAudioDuration(filePath: string): Promise<number> {
|
|||
]);
|
||||
|
||||
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) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
ffprobe.on('close', (code) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
if (code === 0) {
|
||||
const duration = parseFloat(output.trim());
|
||||
resolve(duration);
|
||||
|
|
@ -35,20 +64,44 @@ async function getAudioDuration(filePath: string): Promise<number> {
|
|||
});
|
||||
|
||||
ffprobe.on('error', (err) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runFFmpeg(args: string[]): Promise<void> {
|
||||
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
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) => {
|
||||
console.error(`ffmpeg stderr: ${data}`);
|
||||
});
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
|
|
@ -57,6 +110,9 @@ async function runFFmpeg(args: string[]): Promise<void> {
|
|||
});
|
||||
|
||||
ffmpeg.on('error', (err) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
|
@ -66,51 +122,84 @@ export async function POST(request: NextRequest) {
|
|||
try {
|
||||
// Parse the request body
|
||||
const data: ConversionRequest = await request.json();
|
||||
const format = data.format || 'm4b';
|
||||
|
||||
// Create temp directory if it doesn't exist
|
||||
const tempDir = join(process.cwd(), 'temp');
|
||||
if (!existsSync(tempDir)) {
|
||||
await mkdir(tempDir);
|
||||
// Use docstore directory
|
||||
const docstoreDir = join(process.cwd(), 'docstore');
|
||||
if (!existsSync(docstoreDir)) {
|
||||
await mkdir(docstoreDir);
|
||||
}
|
||||
|
||||
// Generate or use existing book ID
|
||||
const bookId = data.bookId || randomUUID();
|
||||
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
|
||||
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||
|
||||
// Create intermediate directory
|
||||
if (!existsSync(intermediateDir)) {
|
||||
await mkdir(intermediateDir);
|
||||
}
|
||||
|
||||
// Count existing files to determine chapter index
|
||||
const files = await readdir(intermediateDir);
|
||||
const wavFiles = files.filter(f => f.endsWith('.wav'));
|
||||
const chapterIndex = wavFiles.length;
|
||||
// Use provided chapter index or find the next available index robustly (handles gaps)
|
||||
let chapterIndex: number;
|
||||
if (data.chapterIndex !== undefined) {
|
||||
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
|
||||
const inputPath = join(intermediateDir, `${chapterIndex}-input.aac`);
|
||||
const outputPath = join(intermediateDir, `${chapterIndex}.wav`);
|
||||
// Write input file (MP3 from TTS)
|
||||
const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`);
|
||||
const chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
|
||||
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
||||
|
||||
// Write the chapter audio to a temp file
|
||||
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
|
||||
|
||||
// Convert to WAV from raw aac with consistent format
|
||||
await runFFmpeg([
|
||||
'-i', inputPath,
|
||||
'-f', 'wav',
|
||||
'-c:a', 'copy',
|
||||
'-preset', 'ultrafast',
|
||||
'-threads', '0',
|
||||
outputPath
|
||||
]);
|
||||
if (format === 'mp3') {
|
||||
// For MP3, re-encode to ensure proper headers and consistent format
|
||||
await runFFmpeg([
|
||||
'-y', // Overwrite output file without asking
|
||||
'-i', inputPath,
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', '64k',
|
||||
'-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
|
||||
const duration = await getAudioDuration(outputPath);
|
||||
const duration = await getAudioDuration(chapterOutputPath, request.signal);
|
||||
await writeFile(metadataPath, JSON.stringify({
|
||||
title: data.chapterTitle,
|
||||
duration,
|
||||
index: chapterIndex
|
||||
index: chapterIndex,
|
||||
format
|
||||
}));
|
||||
|
||||
// Clean up input file
|
||||
|
|
@ -123,9 +212,15 @@ export async function POST(request: NextRequest) {
|
|||
});
|
||||
|
||||
} 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);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process audio chapter' },
|
||||
{ error: 'Failed to process audio chapter' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
@ -134,15 +229,13 @@ export async function POST(request: NextRequest) {
|
|||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
|
||||
if (!bookId) {
|
||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tempDir = join(process.cwd(), 'temp');
|
||||
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
|
||||
const outputPath = join(tempDir, `${bookId}.m4b`);
|
||||
const metadataPath = join(tempDir, `${bookId}-metadata.txt`);
|
||||
const listPath = join(tempDir, `${bookId}-list.txt`);
|
||||
const docstoreDir = join(process.cwd(), 'docstore');
|
||||
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||
|
||||
if (!existsSync(intermediateDir)) {
|
||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||
|
|
@ -151,21 +244,39 @@ export async function GET(request: NextRequest) {
|
|||
// Read all chapter metadata
|
||||
const files = await readdir(intermediateDir);
|
||||
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) {
|
||||
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
|
||||
chapters.push(meta);
|
||||
}
|
||||
|
||||
if (chapters.length === 0) {
|
||||
return NextResponse.json({ error: 'No chapters found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Sort chapters by 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[] = [];
|
||||
let currentTime = 0;
|
||||
|
||||
// Calculate chapter timings based on actual durations
|
||||
chapters.forEach((chapter) => {
|
||||
const startMs = Math.floor(currentTime * 1000);
|
||||
currentTime += chapter.duration;
|
||||
|
|
@ -185,72 +296,86 @@ export async function GET(request: NextRequest) {
|
|||
// Create list file for concat
|
||||
await writeFile(
|
||||
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
|
||||
await runFFmpeg([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', listPath,
|
||||
'-i', metadataPath,
|
||||
'-map_metadata', '1',
|
||||
'-c:a', 'copy', // c:a is codec for audio and :a is stream specifier
|
||||
//'-codec', 'wav',
|
||||
//'-b:a', '192k',
|
||||
//'-threads', '0', // Use maximum available threads
|
||||
//'-movflags', '+faststart',
|
||||
//'-preset', 'ultrafast', // Use fastest encoding preset
|
||||
outputPath
|
||||
if (format === 'mp3') {
|
||||
// For MP3, re-encode to properly rebuild headers and duration metadata
|
||||
// Using libmp3lame to ensure proper MP3 structure
|
||||
await runFFmpeg([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', listPath,
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', '64k',
|
||||
outputPath
|
||||
], request.signal);
|
||||
} else {
|
||||
// Combine all files into a single M4B with chapter metadata
|
||||
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
|
||||
const stream = createReadStream(outputPath);
|
||||
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
return streamFile(outputPath, format);
|
||||
|
||||
} catch (error) {
|
||||
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
||||
return NextResponse.json(
|
||||
{ error: 'cancelled' },
|
||||
{ status: 499 }
|
||||
);
|
||||
}
|
||||
console.error('Error creating M4B:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create M4B file' },
|
||||
{ error: 'Failed to create M4B file' },
|
||||
{ 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 { writeFile, mkdir, unlink, readFile } from 'fs/promises';
|
||||
import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
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() {
|
||||
if (!existsSync(DOCSTORE_DIR)) {
|
||||
await mkdir(DOCSTORE_DIR, { recursive: true });
|
||||
}
|
||||
if (!existsSync(TEMP_DIR)) {
|
||||
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) => {
|
||||
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',
|
||||
'--nologo',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', outputDir,
|
||||
inputPath
|
||||
]);
|
||||
);
|
||||
const process = spawn('soffice', args);
|
||||
|
||||
process.on('error', (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) {
|
||||
try {
|
||||
await ensureTempDir();
|
||||
|
|
@ -59,24 +96,25 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const tempId = randomUUID();
|
||||
const inputPath = path.join(TEMP_DIR, `${tempId}.docx`);
|
||||
const outputPath = path.join(TEMP_DIR, `${tempId}.pdf`);
|
||||
const jobDir = path.join(TEMP_DIR, tempId);
|
||||
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
|
||||
await writeFile(inputPath, buffer);
|
||||
|
||||
try {
|
||||
// Convert the file
|
||||
await convertDocxToPdf(inputPath, TEMP_DIR);
|
||||
await convertDocxToPdf(inputPath, jobDir, profileDir);
|
||||
|
||||
// Return the PDF file
|
||||
const pdfContent = await readFile(outputPath);
|
||||
|
||||
const pdfPath = await waitForPdfReady(jobDir);
|
||||
const pdfContent = await readFile(pdfPath);
|
||||
|
||||
// Clean up temp files
|
||||
await Promise.all([
|
||||
unlink(inputPath),
|
||||
unlink(outputPath)
|
||||
]).catch(console.error);
|
||||
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
|
||||
|
||||
return new NextResponse(pdfContent, {
|
||||
headers: {
|
||||
|
|
@ -86,11 +124,7 @@ export async function POST(req: NextRequest) {
|
|||
});
|
||||
} catch (error) {
|
||||
// Clean up temp files on error
|
||||
await Promise.all([
|
||||
unlink(inputPath),
|
||||
unlink(outputPath)
|
||||
]).catch(console.error);
|
||||
|
||||
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { writeFile, readFile, readdir, mkdir, unlink } from 'fs/promises';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import type { BaseDocument, SyncedDocument } from '@/types/documents';
|
||||
|
||||
const DOCS_DIR = path.join(process.cwd(), 'docstore');
|
||||
|
||||
|
|
@ -17,9 +18,10 @@ export async function POST(req: NextRequest) {
|
|||
try {
|
||||
await ensureDocsDir();
|
||||
const data = await req.json();
|
||||
const documents = data.documents as SyncedDocument[];
|
||||
|
||||
// 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 contentPath = path.join(DOCS_DIR, `${doc.id}.${doc.type}`);
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ export async function POST(req: NextRequest) {
|
|||
export async function GET() {
|
||||
try {
|
||||
await ensureDocsDir();
|
||||
const documents = [];
|
||||
const documents: SyncedDocument[] = [];
|
||||
|
||||
const files = await readdir(DOCS_DIR);
|
||||
const jsonFiles = files.filter(file => file.endsWith('.json'));
|
||||
|
|
@ -58,7 +60,7 @@ export async function GET() {
|
|||
const docPath = path.join(DOCS_DIR, file);
|
||||
|
||||
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 content = await readFile(contentPath);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,101 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import OpenAI from 'openai';
|
||||
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 ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
||||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||
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) {
|
||||
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 openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
|
||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||
const { text, voice, speed, format, model, instructions } = await req.json();
|
||||
console.log('Received TTS request:', text, voice, speed, format, model);
|
||||
|
||||
if (!openApiKey) {
|
||||
return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 });
|
||||
}
|
||||
const body = (await req.json()) as TTSRequestPayload;
|
||||
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 (!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
|
||||
const finalModel = provider === 'deepinfra' && !model ? 'hexgrad/Kokoro-82M' : model;
|
||||
const finalVoice = provider === 'deepinfra' && !voice ? 'af_bella' : voice;
|
||||
|
||||
// Initialize OpenAI client with abort signal
|
||||
// Initialize OpenAI client with abort signal (OpenAI/deepinfra)
|
||||
const openai = new OpenAI({
|
||||
apiKey: openApiKey,
|
||||
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 = {
|
||||
model: finalModel || 'tts-1',
|
||||
voice: finalVoice as "alloy",
|
||||
model: model,
|
||||
voice: normalizedVoice,
|
||||
input: text,
|
||||
speed: speed,
|
||||
response_format: format === 'aac' ? 'aac' : 'mp3',
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal: req.signal });
|
||||
|
||||
// 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
|
||||
// Compute cache key and check LRU before making provider call
|
||||
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: {
|
||||
'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) {
|
||||
|
|
@ -70,8 +270,13 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
|
||||
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(
|
||||
{ error: 'Failed to generate audio' },
|
||||
errorBody,
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isKokoroModel } from '@/utils/voice';
|
||||
|
||||
const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', '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
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +77,7 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
//console.log('Deepinfra voices response:', data);
|
||||
|
||||
// Extract voice names from the response, excluding preset voices
|
||||
if (data.voices && Array.isArray(data.voices)) {
|
||||
return data.voices
|
||||
|
|
|
|||
|
|
@ -6,18 +6,26 @@ import { useCallback, useEffect, useState } from 'react';
|
|||
import { useEPUB } from '@/contexts/EPUBContext';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { EPUBViewer } from '@/components/EPUBViewer';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||
import { SettingsIcon } from '@/components/icons/Icons';
|
||||
import { Header } from '@/components/Header';
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB();
|
||||
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
||||
const { stop } = useTTS();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 () => {
|
||||
console.log('Loading new epub (from page.tsx)');
|
||||
|
|
@ -43,6 +51,54 @@ export default function EPUBPage() {
|
|||
loadDocument();
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
|
|
@ -50,7 +106,7 @@ export default function EPUBPage() {
|
|||
<Link
|
||||
href="/"
|
||||
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">
|
||||
<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 (
|
||||
<>
|
||||
<div className="p-2 pb-2 border-b border-offbase">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => clearCurrDoc()}
|
||||
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]"
|
||||
<Header
|
||||
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-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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
<Button
|
||||
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
aria-label="View Settings"
|
||||
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 settings"
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
||||
{isLoading ? 'Loading...' : currDocName}
|
||||
</h1>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||
{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>
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<EPUBViewer className="p-4" />
|
||||
)}
|
||||
<AudiobookExportModal
|
||||
isOpen={isAudiobookModalOpen}
|
||||
setIsOpen={setIsAudiobookModalOpen}
|
||||
documentType="epub"
|
||||
documentId={id as string}
|
||||
onGenerateAudiobook={handleGenerateAudiobook}
|
||||
onRegenerateChapter={handleRegenerateChapter}
|
||||
/>
|
||||
<TTSPlayer />
|
||||
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ html.light {
|
|||
--base: #f7fafc;
|
||||
--offbase: #e2e8f0;
|
||||
--accent: #ef4444;
|
||||
--secondary-accent: #ed6868;
|
||||
--muted: #718096;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#fecaca,
|
||||
#f87171,
|
||||
#ef4444
|
||||
);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
|
|
@ -19,7 +25,13 @@ html.dark {
|
|||
--base: #171717;
|
||||
--offbase: #343434;
|
||||
--accent: #f87171;
|
||||
--secondary-accent: #eb6262;
|
||||
--muted: #a3a3a3;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#fca5a5,
|
||||
#fb7185,
|
||||
#f87171
|
||||
);
|
||||
}
|
||||
|
||||
html.ocean {
|
||||
|
|
@ -28,7 +40,13 @@ html.ocean {
|
|||
--base: #0f172a;
|
||||
--offbase: #1e293b;
|
||||
--accent: #38bdf8;
|
||||
--secondary-accent: #22d3ee;
|
||||
--muted: #94a3b8;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#7dd3fc,
|
||||
#38bdf8,
|
||||
#0ea5e9
|
||||
);
|
||||
}
|
||||
|
||||
html.forest {
|
||||
|
|
@ -37,7 +55,13 @@ html.forest {
|
|||
--base: #111a15;
|
||||
--offbase: #1a2820;
|
||||
--accent: #4ade80;
|
||||
--secondary-accent: #22c55e;
|
||||
--muted: #7c8f85;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#86efac,
|
||||
#4ade80,
|
||||
#22c55e
|
||||
);
|
||||
}
|
||||
|
||||
html.sunset {
|
||||
|
|
@ -46,7 +70,13 @@ html.sunset {
|
|||
--base: #2c1810;
|
||||
--offbase: #3d1f14;
|
||||
--accent: #ff6b6b;
|
||||
--secondary-accent: #f59e0b;
|
||||
--muted: #bc8f8f;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#fca5a5,
|
||||
#fb7185,
|
||||
#ff6b6b
|
||||
);
|
||||
}
|
||||
|
||||
html.sea {
|
||||
|
|
@ -55,7 +85,13 @@ html.sea {
|
|||
--base: #102c3d;
|
||||
--offbase: #1a3c52;
|
||||
--accent: #06b6d4;
|
||||
--secondary-accent: #0ea5e9;
|
||||
--muted: #7ca7c4;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#67e8f9,
|
||||
#22d3ee,
|
||||
#06b6d4
|
||||
);
|
||||
}
|
||||
|
||||
html.mint {
|
||||
|
|
@ -64,7 +100,13 @@ html.mint {
|
|||
--base: #132d27;
|
||||
--offbase: #1c3d35;
|
||||
--accent: #2dd4bf;
|
||||
--secondary-accent: #10b981;
|
||||
--muted: #75a99c;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#99f6e4,
|
||||
#5eead4,
|
||||
#2dd4bf
|
||||
);
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -76,3 +118,75 @@ body {
|
|||
-webkit-font-smoothing: antialiased;
|
||||
-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 { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { HTMLViewer } from '@/components/HTMLViewer';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||
import { SettingsIcon } from '@/components/icons/Icons';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useTTS } from "@/contexts/TTSContext";
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { ZoomControl } from '@/components/ZoomControl';
|
||||
|
||||
export default function HTMLPage() {
|
||||
const { id } = useParams();
|
||||
|
|
@ -18,6 +20,9 @@ export default function HTMLPage() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 () => {
|
||||
if (!isLoading) return;
|
||||
|
|
@ -41,6 +46,29 @@ export default function HTMLPage() {
|
|||
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) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
|
|
@ -48,7 +76,7 @@ export default function HTMLPage() {
|
|||
<Link
|
||||
href="/"
|
||||
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">
|
||||
<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 (
|
||||
<>
|
||||
<div className="p-2 pb-2 border-b border-offbase">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => {clearCurrDoc();}}
|
||||
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]"
|
||||
>
|
||||
<svg className="w-4 h-4 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>
|
||||
<Button
|
||||
<Header
|
||||
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-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)}
|
||||
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"
|
||||
aria-label="View Settings"
|
||||
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="Open settings"
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
||||
{isLoading ? 'Loading...' : currDocName}
|
||||
</h1>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||
{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>
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<HTMLViewer className="p-4" />
|
||||
)}
|
||||
<TTSPlayer />
|
||||
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Providers } from "@/app/providers";
|
|||
import { Metadata } from "next";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenReader WebUI",
|
||||
|
|
@ -56,14 +56,16 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||
</head>
|
||||
<body className="antialiased">
|
||||
<Providers>
|
||||
<div className="min-h-screen bg-background p-4">
|
||||
<div className="relative max-w-6xl mx-auto align-center">
|
||||
<div className="bg-base rounded-lg shadow-lg">
|
||||
{children}
|
||||
<Analytics />
|
||||
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||
<main className="flex-1 flex flex-col">
|
||||
{children}
|
||||
<Analytics />
|
||||
</main>
|
||||
{!isDev && (
|
||||
<div className="px-4 py-3">
|
||||
<Footer />
|
||||
</div>
|
||||
{!isDev && <Footer />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toaster />
|
||||
</Providers>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||
import { DocumentList } from '@/components/doclist/DocumentList';
|
||||
import { HomeContent } from '@/components/HomeContent';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
|
||||
// Home page redesigned for fullscreen layout: hero + document area.
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className='p-3.5 sm:p-5'>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<SettingsModal />
|
||||
<h1 className="text-xl font-bold text-center flex-grow">OpenReader WebUI</h1>
|
||||
<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="flex flex-col items-center gap-5">
|
||||
<DocumentUploader className='max-w-xl' />
|
||||
<DocumentList />
|
||||
</div>
|
||||
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
|
||||
<p className="text-sm leading-relaxed max-w-prose text-foreground">
|
||||
Bring your own text-to-speech API.
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ import Link from 'next/link';
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { Button } from '@headlessui/react';
|
||||
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
|
||||
const PDFViewer = dynamic(
|
||||
|
|
@ -22,12 +25,14 @@ const PDFViewer = dynamic(
|
|||
|
||||
export default function PDFViewerPage() {
|
||||
const { id } = useParams();
|
||||
const { setCurrentDocument, currDocName, clearCurrDoc } = usePDF();
|
||||
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
||||
const { stop } = useTTS();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||
|
||||
const loadDocument = useCallback(async () => {
|
||||
if (!isLoading) return; // Prevent calls when not loading new doc
|
||||
|
|
@ -51,9 +56,43 @@ export default function PDFViewerPage() {
|
|||
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 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) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
|
|
@ -61,7 +100,7 @@ export default function PDFViewerPage() {
|
|||
<Link
|
||||
href="/"
|
||||
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">
|
||||
<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 (
|
||||
<>
|
||||
<div className="p-2 pb-2 border-b border-offbase">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<Header
|
||||
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">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => {clearCurrDoc();}}
|
||||
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]"
|
||||
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
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
|
||||
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
aria-label="View Settings"
|
||||
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 settings"
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
||||
{isLoading ? 'Loading...' : currDocName}
|
||||
</h1>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<PDFViewer zoomLevel={zoomLevel} />
|
||||
)}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<PDFViewer zoomLevel={zoomLevel} />
|
||||
)}
|
||||
<AudiobookExportModal
|
||||
isOpen={isAudiobookModalOpen}
|
||||
setIsOpen={setIsAudiobookModalOpen}
|
||||
documentType="pdf"
|
||||
documentId={id as string}
|
||||
onGenerateAudiobook={handleGenerateAudiobook}
|
||||
onRegenerateChapter={handleRegenerateChapter}
|
||||
/>
|
||||
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
||||
<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}>
|
||||
<Dialog
|
||||
as="div"
|
||||
role={undefined}
|
||||
className="relative z-50"
|
||||
onClose={onClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
@ -46,7 +47,7 @@ export function ConfirmDialog({
|
|||
leaveFrom="opacity-100"
|
||||
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>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
|
|
@ -60,7 +61,7 @@ export function ConfirmDialog({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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
|
||||
as="h3"
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
|
||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
||||
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={onClose}
|
||||
|
|
@ -84,12 +85,12 @@ export function ConfirmDialog({
|
|||
</button>
|
||||
<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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
|
||||
${isDangerous
|
||||
? 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 hover:text-white'
|
||||
: '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'
|
||||
: 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
|
||||
}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,22 @@
|
|||
'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 { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||
import { useEPUB } from '@/contexts/EPUBContext';
|
||||
import { usePDF } from '@/contexts/PDFContext';
|
||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
const viewTypes = [
|
||||
const viewTypeTextMapping = [
|
||||
{ id: 'single', name: 'Single Page' },
|
||||
{ id: 'dual', name: 'Two Pages' },
|
||||
{ id: 'scroll', name: 'Continuous Scroll' },
|
||||
];
|
||||
|
||||
const audioFormats = [
|
||||
{ id: 'mp3', name: 'MP3' },
|
||||
{ id: 'm4b', name: 'M4B' },
|
||||
];
|
||||
|
||||
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||
isOpen: boolean,
|
||||
setIsOpen: (isOpen: boolean) => void,
|
||||
|
|
@ -32,25 +27,25 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
viewType,
|
||||
skipBlank,
|
||||
epubTheme,
|
||||
smartSentenceSplitting,
|
||||
headerMargin,
|
||||
footerMargin,
|
||||
leftMargin,
|
||||
rightMargin,
|
||||
updateConfigKey
|
||||
updateConfigKey,
|
||||
pdfHighlightEnabled,
|
||||
} = useConfig();
|
||||
const { createFullAudioBook, isAudioCombining } = useEPUB();
|
||||
const { createFullAudioBook: createPDFAudioBook, isAudioCombining: isPDFAudioCombining } = usePDF();
|
||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [audioFormat, setAudioFormat] = useState<'mp3' | 'm4b'>('mp3');
|
||||
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
||||
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
||||
const { id } = useParams();
|
||||
const [localMargins, setLocalMargins] = useState({
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
});
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||
|
||||
// Sync local margins with global state
|
||||
useEffect(() => {
|
||||
|
|
@ -79,61 +74,41 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
}
|
||||
};
|
||||
|
||||
const handleStartGeneration = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
setProgress(0);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const audioBuffer = epub ? await createFullAudioBook(
|
||||
(progress) => setProgress(progress),
|
||||
abortControllerRef.current.signal,
|
||||
audioFormat
|
||||
) : 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;
|
||||
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'
|
||||
) => {
|
||||
if (epub) {
|
||||
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||
} else {
|
||||
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||
}
|
||||
}, [createFullAudioBook, createPDFAudioBook, epub, audioFormat, setProgress]);
|
||||
}, [epub, createEPUBAudioBook, createPDFAudioBook, id]);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
const handleRegenerateChapter = useCallback(async (
|
||||
chapterIndex: number,
|
||||
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 (
|
||||
<>
|
||||
<ProgressPopup
|
||||
isOpen={isGenerating}
|
||||
progress={progress}
|
||||
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||
onCancel={handleCancel}
|
||||
isProcessing={epub ? isAudioCombining : isPDFAudioCombining}
|
||||
cancelText="Cancel and download"
|
||||
<AudiobookExportModal
|
||||
isOpen={isAudiobookModalOpen}
|
||||
setIsOpen={setIsAudiobookModalOpen}
|
||||
documentType={epub ? 'epub' : 'pdf'}
|
||||
documentId={id as string}
|
||||
onGenerateAudiobook={handleGenerateAudiobook}
|
||||
onRegenerateChapter={handleRegenerateChapter}
|
||||
/>
|
||||
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
|
|
@ -147,7 +122,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
leaveFrom="opacity-100"
|
||||
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>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
|
|
@ -162,44 +137,17 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
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">
|
||||
{isDev && <div className="space-y-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isGenerating}
|
||||
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
||||
font-medium text-background hover:opacity-95 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]"
|
||||
onClick={handleStartGeneration}
|
||||
>
|
||||
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>
|
||||
{isDev && !html && <div className="space-y-2 mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full 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] hover:text-background"
|
||||
onClick={() => setIsAudiobookModalOpen(true)}
|
||||
>
|
||||
Export Audiobook
|
||||
</Button>
|
||||
</div>}
|
||||
|
||||
<div className="space-y-4">
|
||||
|
|
@ -299,7 +247,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
>
|
||||
<div className="relative z-10 space-y-2">
|
||||
<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="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||
|
|
@ -312,11 +260,11 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
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">
|
||||
{viewTypes.map((view) => (
|
||||
{viewTypeTextMapping.map((view) => (
|
||||
<ListboxOption
|
||||
key={view.id}
|
||||
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}
|
||||
|
|
@ -361,6 +309,40 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
|||
Automatically skip pages with no text content
|
||||
</p>
|
||||
</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 && (
|
||||
<div className="space-y-1">
|
||||
<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">
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
|
||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
||||
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 z-1"
|
||||
onClick={() => setIsOpen(false)}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
|
|||
|
||||
interface DocumentUploaderProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||
export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) {
|
||||
const {
|
||||
addPDFDocument: addPDF,
|
||||
addEPUBDocument: addEPUB,
|
||||
|
|
@ -87,41 +88,51 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
|||
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 (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`
|
||||
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}
|
||||
`}
|
||||
className={`${containerBase} ${paddingClass}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
||||
|
||||
{isUploading ? (
|
||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
||||
Uploading file...
|
||||
</p>
|
||||
) : isConverting ? (
|
||||
<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>
|
||||
{variant === 'compact' ? (
|
||||
<div className="flex items-center gap-2 text-left">
|
||||
<UploadIcon className="w-5 h-5 text-muted" />
|
||||
{isUploading ? (
|
||||
<p className="text-xs font-medium text-foreground">Uploading…</p>
|
||||
) : isConverting ? (
|
||||
<p className="text-xs font-medium text-foreground">Converting DOCX…</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{isDragActive ? 'Drop files here' : 'Drop files or click'}
|
||||
</p>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
||||
{isUploading ? (
|
||||
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
|
||||
) : isConverting ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useEPUB } from '@/contexts/EPUBContext';
|
|||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
||||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||
|
||||
|
|
@ -65,11 +64,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
||||
<div className="z-10">
|
||||
<TTSPlayer />
|
||||
</div>
|
||||
<div className="flex-1 -mt-16 pt-16">
|
||||
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
|
||||
<div className="flex-1">
|
||||
<ReactReader
|
||||
loadingView={<DocumentSkeleton />}
|
||||
key={'epub-reader'}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useRef } from 'react';
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useHTML } from '@/contexts/HTMLContext';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
|
||||
interface HTMLViewerProps {
|
||||
|
|
@ -20,24 +19,21 @@ export function HTMLViewer({ className = '' }: HTMLViewerProps) {
|
|||
}
|
||||
|
||||
// Check if the file is a txt file
|
||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
||||
<div className="z-10">
|
||||
<TTSPlayer />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className={`min-w-full px-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
||||
{isTxtFile ? (
|
||||
currDocData
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{currDocData}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className={`html-container min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
||||
{isTxtFile ? (
|
||||
currDocData
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{currDocData}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</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 { 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/TextLayer.css';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { usePDF } from '@/contexts/PDFContext';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { usePDFResize } from '@/hooks/pdf/usePDFResize';
|
||||
|
||||
|
|
@ -15,9 +15,9 @@ interface PDFViewerProps {
|
|||
zoomLevel: number;
|
||||
}
|
||||
|
||||
interface OnItemClickArgs {
|
||||
interface PDFOnLinkClickArgs {
|
||||
pageNumber?: number;
|
||||
dest?: unknown;
|
||||
dest?: Dest;
|
||||
}
|
||||
|
||||
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||
|
|
@ -26,13 +26,11 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
const { containerWidth } = usePDFResize(containerRef);
|
||||
|
||||
// Config context
|
||||
const { viewType } = useConfig();
|
||||
const { viewType, pdfHighlightEnabled } = useConfig();
|
||||
|
||||
// TTS context
|
||||
const {
|
||||
currentSentence,
|
||||
stopAndPlayFromIndex,
|
||||
isProcessing,
|
||||
skipToLocation,
|
||||
} = useTTS();
|
||||
|
||||
|
|
@ -40,7 +38,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
const {
|
||||
highlightPattern,
|
||||
clearHighlights,
|
||||
handleTextClick,
|
||||
onDocumentLoadSuccess,
|
||||
currDocData,
|
||||
currDocPages,
|
||||
|
|
@ -48,53 +45,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
currDocPage,
|
||||
} = 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(() => {
|
||||
/*
|
||||
* 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
|
||||
* - clearHighlights: Function from context that could change
|
||||
*/
|
||||
if (!currDocText) return;
|
||||
|
||||
if (!currDocText || !pdfHighlightEnabled) {
|
||||
clearHighlights();
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightTimeout = setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
|
|
@ -118,7 +72,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
clearTimeout(highlightTimeout);
|
||||
clearHighlights();
|
||||
};
|
||||
}, [currDocText, currentSentence, highlightPattern, clearHighlights]);
|
||||
}, [currDocText, currentSentence, highlightPattern, clearHighlights, pdfHighlightEnabled]);
|
||||
|
||||
// Add page dimensions state
|
||||
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
|
||||
const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => {
|
||||
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'
|
||||
? (containerWidth - margin) / 2 // divide by 2 for dual pages
|
||||
: containerWidth - margin;
|
||||
|
|
@ -165,7 +119,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
}, [calculateScale]);
|
||||
|
||||
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
|
||||
loading={<DocumentSkeleton />}
|
||||
noData={<DocumentSkeleton />}
|
||||
|
|
@ -173,12 +127,12 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
onLoadSuccess={(pdf) => {
|
||||
onDocumentLoadSuccess(pdf);
|
||||
}}
|
||||
onItemClick={(args: OnItemClickArgs) => {
|
||||
onItemClick={(args: PDFOnLinkClickArgs) => {
|
||||
if (args?.pageNumber) {
|
||||
skipToLocation(args.pageNumber, true);
|
||||
} else if (args?.dest) {
|
||||
const destArray = Array.isArray(args.dest) ? args.dest : [];
|
||||
const pageNum = typeof destArray[0] === 'number' ? destArray[0] + 1 : undefined;
|
||||
const destArray = args.dest as Array<number> || [];
|
||||
const pageNum = destArray[0] + 1 || null;
|
||||
if (pageNum) {
|
||||
skipToLocation(pageNum, true);
|
||||
}
|
||||
|
|
@ -240,10 +194,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
)}
|
||||
</div>
|
||||
</Document>
|
||||
<TTSPlayer
|
||||
currentPage={currDocPage}
|
||||
numPages={currDocPages}
|
||||
/>
|
||||
</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 { Transition } from '@headlessui/react';
|
||||
import { LoadingSpinner } from './Spinner';
|
||||
import { ProgressCard } from './ProgressCard';
|
||||
|
||||
interface ProgressPopupProps {
|
||||
isOpen: boolean;
|
||||
progress: number;
|
||||
estimatedTimeRemaining?: string;
|
||||
onCancel: () => void;
|
||||
isProcessing: boolean;
|
||||
statusMessage?: string;
|
||||
operationType?: 'sync' | 'load';
|
||||
operationType?: 'sync' | 'load' | 'audiobook';
|
||||
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 (
|
||||
<Transition
|
||||
show={isOpen}
|
||||
|
|
@ -25,46 +39,27 @@ export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCanc
|
|||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-4"
|
||||
>
|
||||
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none">
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<div className="w-full transform rounded-lg bg-offbase p-4 shadow-xl pointer-events-auto">
|
||||
<div className="space-y-2 truncate">
|
||||
<div className="w-full bg-background rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="h-2 bg-accent transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm text-muted text-xs sm:text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
{operationType && (
|
||||
<span className="text-accent font-semibold text-xs uppercase tracking-wide">
|
||||
{operationType === 'sync' ? 'Saving to Server' : 'Loading from Server'}
|
||||
</span>
|
||||
)}
|
||||
{statusMessage && <span className="text-foreground font-medium">{statusMessage}</span>}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span>{Math.round(progress)}% complete</span>
|
||||
{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 className="fixed inset-x-0 top-2 z-[60] pointer-events-none px-4">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div
|
||||
className={`pointer-events-auto shadow-xl ${
|
||||
onClick ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ProgressCard
|
||||
progress={progress}
|
||||
estimatedTimeRemaining={estimatedTimeRemaining}
|
||||
onCancel={(e) => {
|
||||
e?.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
operationType={operationType}
|
||||
cancelText={cancelText}
|
||||
currentChapter={currentChapter}
|
||||
completedChapters={completedChapters}
|
||||
statusMessage={statusMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,9 +22,8 @@ import {
|
|||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
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 { setItem, getItem } from '@/utils/indexedDB';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||
|
|
@ -42,7 +41,7 @@ export function SettingsModal() {
|
|||
|
||||
const { theme, setTheme } = useTheme();
|
||||
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 [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||
const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider);
|
||||
|
|
@ -123,9 +122,9 @@ export function SettingsModal() {
|
|||
// set firstVisit on initial load
|
||||
const checkFirstVist = useCallback(async () => {
|
||||
if (!isDev) return;
|
||||
const firstVisit = await getItem('firstVisit');
|
||||
if (firstVisit == null) {
|
||||
await setItem('firstVisit', 'true');
|
||||
const firstVisit = await getFirstVisit();
|
||||
if (!firstVisit) {
|
||||
await setFirstVisit(true);
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [setIsOpen]);
|
||||
|
|
@ -158,7 +157,7 @@ export function SettingsModal() {
|
|||
setProgress(0);
|
||||
setOperationType('sync');
|
||||
setStatusMessage('Preparing documents...');
|
||||
await indexedDBService.syncToServer((progress, status) => {
|
||||
await syncDocumentsToServer((progress, status) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setProgress(progress);
|
||||
if (status) setStatusMessage(status);
|
||||
|
|
@ -190,14 +189,13 @@ export function SettingsModal() {
|
|||
setProgress(0);
|
||||
setOperationType('load');
|
||||
setStatusMessage('Downloading documents from server...');
|
||||
await indexedDBService.loadFromServer((progress, status) => {
|
||||
await loadDocumentsFromServer((progress, status) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setProgress(progress);
|
||||
if (status) setStatusMessage(status);
|
||||
}, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setStatusMessage('Refreshing document list...');
|
||||
await Promise.all([refreshPDFs(), refreshEPUBs()]);
|
||||
setStatusMessage('Documents loaded from server');
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
console.log('Load operation cancelled');
|
||||
|
|
@ -218,6 +216,7 @@ export function SettingsModal() {
|
|||
const handleClearLocal = async () => {
|
||||
await clearPDFs();
|
||||
await clearEPUBs();
|
||||
await clearHTML();
|
||||
setShowClearLocalConfirm(false);
|
||||
};
|
||||
|
||||
|
|
@ -267,11 +266,11 @@ export function SettingsModal() {
|
|||
<>
|
||||
<Button
|
||||
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"
|
||||
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>
|
||||
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
|
|
@ -285,7 +284,7 @@ export function SettingsModal() {
|
|||
leaveFrom="opacity-100"
|
||||
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>
|
||||
|
||||
<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
|
||||
ring-accent/60 ring-offset-2 ring-offset-base
|
||||
${selected
|
||||
? 'bg-accent text-white shadow'
|
||||
: 'text-foreground hover:bg-accent/[0.12] hover:text-accent'
|
||||
? 'bg-accent text-background shadow'
|
||||
: 'text-foreground hover:text-accent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
|
|
@ -329,8 +328,8 @@ export function SettingsModal() {
|
|||
))}
|
||||
</TabList>
|
||||
<TabPanels className="mt-2">
|
||||
<TabPanel className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<TabPanel className="space-y-2.5">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
|
||||
<Listbox
|
||||
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
|
||||
|
|
@ -351,7 +350,7 @@ export function SettingsModal() {
|
|||
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">
|
||||
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
|
||||
</span>
|
||||
|
|
@ -370,8 +369,8 @@ export function SettingsModal() {
|
|||
<ListboxOption
|
||||
key={provider.id}
|
||||
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={provider}
|
||||
|
|
@ -395,7 +394,7 @@ export function SettingsModal() {
|
|||
</Listbox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
API Key
|
||||
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
|
|
@ -406,12 +405,12 @@ export function SettingsModal() {
|
|||
value={localApiKey}
|
||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||
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 className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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">
|
||||
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
|
||||
</span>
|
||||
|
|
@ -445,8 +444,8 @@ export function SettingsModal() {
|
|||
<ListboxOption
|
||||
key={model.id}
|
||||
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={model}
|
||||
|
|
@ -478,26 +477,26 @@ export function SettingsModal() {
|
|||
setModelValue(e.target.value);
|
||||
}}
|
||||
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>
|
||||
|
||||
{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>
|
||||
<textarea
|
||||
value={localTTSInstructions}
|
||||
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
API Base URL
|
||||
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
|
|
@ -508,17 +507,17 @@ export function SettingsModal() {
|
|||
value={localBaseUrl}
|
||||
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
||||
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 className="mt-6 flex justify-end gap-2">
|
||||
<div className="pt-4 flex justify-end gap-2">
|
||||
<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-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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
onClick={async () => {
|
||||
|
|
@ -534,8 +533,8 @@ export function SettingsModal() {
|
|||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
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
|
||||
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] hover:text-background"
|
||||
disabled={!canSubmit}
|
||||
|
|
@ -557,10 +556,10 @@ export function SettingsModal() {
|
|||
</TabPanel>
|
||||
|
||||
<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>
|
||||
<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="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||
|
|
@ -572,12 +571,12 @@ export function SettingsModal() {
|
|||
leaveFrom="opacity-100"
|
||||
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) => (
|
||||
<ListboxOption
|
||||
key={theme.id}
|
||||
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}
|
||||
|
|
@ -603,14 +602,14 @@ export function SettingsModal() {
|
|||
</TabPanel>
|
||||
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleLoad}
|
||||
disabled={isSyncing || isLoading}
|
||||
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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||
disabled:opacity-50"
|
||||
|
|
@ -621,7 +620,7 @@ export function SettingsModal() {
|
|||
onClick={handleSync}
|
||||
disabled={isSyncing || isLoading}
|
||||
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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||
disabled:opacity-50"
|
||||
|
|
@ -631,23 +630,23 @@ export function SettingsModal() {
|
|||
</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>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setShowClearLocalConfirm(true)}
|
||||
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
|
||||
font-medium text-white hover:bg-red-700 focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
||||
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]"
|
||||
>
|
||||
Delete local docs
|
||||
</Button>
|
||||
{isDev && <Button
|
||||
onClick={() => setShowClearServerConfirm(true)}
|
||||
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
|
||||
font-medium text-white hover:bg-red-700 focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
||||
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]"
|
||||
>
|
||||
Delete server docs
|
||||
|
|
@ -700,7 +699,6 @@ export function SettingsModal() {
|
|||
setOperationType('sync');
|
||||
setAbortController(null);
|
||||
}}
|
||||
isProcessing={isSyncing || isLoading}
|
||||
statusMessage={statusMessage}
|
||||
operationType={operationType}
|
||||
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"
|
||||
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>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useState, DragEvent } from 'react';
|
||||
import { Button, Transition } from '@headlessui/react';
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
|
|
@ -16,7 +18,7 @@ interface DocumentFolderProps {
|
|||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
|
|
@ -62,36 +64,37 @@ export function DocumentFolder({
|
|||
if (!draggedDoc || draggedDoc.folderId) return;
|
||||
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={`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">
|
||||
<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
|
||||
onClick={() => onToggleCollapse(folder.id)}
|
||||
className="transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:font-semibold hover:text-accent"
|
||||
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||
type="button"
|
||||
onClick={() => onToggleCollapse(folder.id)}
|
||||
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
|
||||
className={`w-5 h-5 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`}
|
||||
/>
|
||||
<ChevronIcon className={`w-4 h-4 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<svg className="w-4 h-4"
|
||||
<svg className="w-3.5 h-3.5"
|
||||
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>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative bg-base px-1 py-1 rounded-b-md">
|
||||
<Transition
|
||||
show={!isCollapsed}
|
||||
enter="transition-all duration-300 ease-out"
|
||||
|
|
@ -101,7 +104,7 @@ export function DocumentFolder({
|
|||
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||
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 => (
|
||||
<DocumentListItem
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
|
|
@ -125,7 +128,7 @@ export function DocumentFolder({
|
|||
leaveFrom="max-h-[50px] opacity-100"
|
||||
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
|
||||
{` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import { useDocuments } from '@/contexts/DocumentContext';
|
|||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
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 { DocumentListItem } from '@/components/doclist/DocumentListItem';
|
||||
import { DocumentFolder } from '@/components/doclist/DocumentFolder';
|
||||
import { SortControls } from '@/components/doclist/SortControls';
|
||||
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||
|
||||
type DocumentToDelete = {
|
||||
id: string;
|
||||
|
|
@ -297,11 +298,18 @@ export function DocumentList() {
|
|||
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 (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="w-full mx-auto">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-4">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Your Documents</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Local Documents</h2>
|
||||
<SortControls
|
||||
sortBy={sortBy}
|
||||
sortDirection={sortDirection}
|
||||
|
|
@ -309,14 +317,21 @@ export function DocumentList() {
|
|||
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||
/>
|
||||
</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">
|
||||
{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">
|
||||
<p className="text-sm">Drag files on top of each other to make folders</p>
|
||||
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm">
|
||||
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<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 { Button } from '@headlessui/react';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { LoadingSpinner } from '@/components/Spinner';
|
||||
import { DocumentListDocument } from '@/types/documents';
|
||||
|
||||
interface DocumentListItemProps {
|
||||
|
|
@ -50,41 +49,44 @@ export function DocumentListItem({
|
|||
onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)}
|
||||
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
||||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||
aria-busy={loading}
|
||||
className={`
|
||||
w-full
|
||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent bg-primary/10' : ''}
|
||||
bg-background rounded-lg p-2 shadow hover:shadow-md transition-shadow
|
||||
relative
|
||||
w-full group
|
||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
||||
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
||||
border border-offbase rounded-md p-1
|
||||
transition-colors duration-150 relative
|
||||
`}
|
||||
>
|
||||
{loading && (
|
||||
<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">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
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}
|
||||
>
|
||||
<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 className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
|
||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
||||
<p className="text-xs sm:text-sm text-muted 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-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">{doc.name}</p>
|
||||
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
|
||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -29,13 +29,13 @@ export function SortControls({
|
|||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
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}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<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>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</ListboxButton>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
export const PlayIcon = () => (
|
||||
export const PlayIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="16"
|
||||
|
|
@ -22,13 +24,15 @@ export const PlayIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export const PauseIcon = () => (
|
||||
export const PauseIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="16"
|
||||
|
|
@ -59,13 +63,15 @@ export const PauseIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export const SkipForwardIcon = () => (
|
||||
export const SkipForwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6 4l10 8-10 8V4z"
|
||||
|
|
@ -84,13 +90,15 @@ export const SkipForwardIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export const SkipBackwardIcon = () => (
|
||||
export const SkipBackwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M18 4L8 12l10 8V4z"
|
||||
|
|
@ -262,3 +270,147 @@ export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
</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
|
||||
onClick={() => skipToLocation(currentPage - 1, true)}
|
||||
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"
|
||||
>
|
||||
<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 (
|
||||
<>
|
||||
<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}
|
||||
>
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
|
|
@ -105,7 +105,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
placeholder={currentPage.toString()}
|
||||
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>
|
||||
</PopoverPanel>
|
||||
</>
|
||||
|
|
@ -117,7 +117,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
<Button
|
||||
onClick={() => skipToLocation(currentPage + 1, true)}
|
||||
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"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
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 { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -54,7 +54,8 @@ export const SpeedControl = ({
|
|||
|
||||
return (
|
||||
<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>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</PopoverButton>
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
} = useTTS();
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
|
||||
<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="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
|
||||
{/* Speed control */}
|
||||
<SpeedControl
|
||||
setSpeedAndRestart={setSpeedAndRestart}
|
||||
|
|
@ -51,29 +51,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
{/* Playback Controls */}
|
||||
<Button
|
||||
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"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
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'}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
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"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Voice control */}
|
||||
|
|
|
|||
|
|
@ -6,41 +6,126 @@ import {
|
|||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/react';
|
||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||
import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { parseKokoroVoiceNames, buildKokoroVoiceString, isKokoroModel, getMaxVoicesForProvider } from '@/utils/voice';
|
||||
|
||||
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
||||
availableVoices: string[];
|
||||
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 currentVoice = (configVoice && availableVoices.includes(configVoice))
|
||||
? configVoice
|
||||
: availableVoices[0] || '';
|
||||
const isKokoro = isKokoroModel(ttsModel);
|
||||
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
|
||||
|
||||
// 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 (
|
||||
<div className="relative">
|
||||
<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">
|
||||
<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 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-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
|
||||
{(isKokoro && maxVoices > 1) ? (
|
||||
<Listbox
|
||||
multiple
|
||||
value={selectedVoices}
|
||||
onChange={(vals: string[]) => {
|
||||
if (!vals || vals.length === 0) return; // prevent empty selection
|
||||
|
||||
let next = vals;
|
||||
|
||||
// Enforce provider max selection
|
||||
if (vals.length > maxVoices) {
|
||||
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>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
}
|
||||
|
||||
setSelectedVoices(next);
|
||||
const combined = buildKokoroVoiceString(next);
|
||||
if (combined) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,35 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { getItem, indexedDBService, setItem, removeItem } from '@/utils/indexedDB';
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
/** 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>;
|
||||
import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db, initDB, updateAppConfig } from '@/lib/dexie';
|
||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
||||
export type { ViewType } from '@/types/config';
|
||||
|
||||
/** 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 ConfigContextType {
|
||||
|
|
@ -41,6 +18,7 @@ interface ConfigContextType {
|
|||
voice: string;
|
||||
skipBlank: boolean;
|
||||
epubTheme: boolean;
|
||||
smartSentenceSplitting: boolean;
|
||||
headerMargin: number;
|
||||
footerMargin: number;
|
||||
leftMargin: number;
|
||||
|
|
@ -50,9 +28,10 @@ interface ConfigContextType {
|
|||
ttsInstructions: string;
|
||||
savedVoices: SavedVoices;
|
||||
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;
|
||||
isDBReady: boolean;
|
||||
pdfHighlightEnabled: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
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 [isDBReady, setIsDBReady] = useState(false);
|
||||
|
||||
|
|
@ -92,155 +53,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const initializeDB = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await indexedDBService.init();
|
||||
await initDB();
|
||||
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) {
|
||||
console.error('Error initializing:', error);
|
||||
console.error('Error initializing Dexie:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -249,6 +65,45 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
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
|
||||
* 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 }>) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (newConfig.apiKey !== undefined && newConfig.apiKey !== '') {
|
||||
// Only save API key to IndexedDB if it's different from env default
|
||||
await setItem('apiKey', newConfig.apiKey!);
|
||||
setApiKey(newConfig.apiKey!);
|
||||
const updates: Partial<AppConfigRow> = {};
|
||||
if (newConfig.apiKey !== undefined) {
|
||||
updates.apiKey = newConfig.apiKey;
|
||||
}
|
||||
if (newConfig.baseUrl !== undefined && newConfig.baseUrl !== '') {
|
||||
// Only save base URL to IndexedDB if it's different from env default
|
||||
await setItem('baseUrl', newConfig.baseUrl!);
|
||||
setBaseUrl(newConfig.baseUrl!);
|
||||
if (newConfig.baseUrl !== undefined) {
|
||||
updates.baseUrl = newConfig.baseUrl;
|
||||
}
|
||||
|
||||
// Delete completely if '' is passed
|
||||
if (newConfig.apiKey === '') {
|
||||
await removeItem('apiKey');
|
||||
setApiKey('');
|
||||
}
|
||||
if (newConfig.baseUrl === '') {
|
||||
await removeItem('baseUrl');
|
||||
setBaseUrl('');
|
||||
}
|
||||
|
||||
await updateAppConfig(updates);
|
||||
} catch (error) {
|
||||
console.error('Error updating config:', error);
|
||||
throw error;
|
||||
|
|
@ -288,9 +130,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
/**
|
||||
* Updates a single configuration value by key
|
||||
* @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 {
|
||||
setIsLoading(true);
|
||||
|
||||
|
|
@ -298,80 +140,35 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (key === 'voice') {
|
||||
const voiceKey = getVoiceKey(ttsProvider, ttsModel);
|
||||
const updatedSavedVoices = { ...savedVoices, [voiceKey]: value as string };
|
||||
setSavedVoices(updatedSavedVoices);
|
||||
await setItem('savedVoices', JSON.stringify(updatedSavedVoices));
|
||||
setVoice(value as string);
|
||||
await updateAppConfig({
|
||||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
});
|
||||
}
|
||||
// Special handling for provider/model changes - restore saved voice if available
|
||||
else if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||
const newProvider = key === 'ttsProvider' ? (value as string) : ttsProvider;
|
||||
const newModel = key === 'ttsModel' ? (value as string) : ttsModel;
|
||||
const voiceKey = getVoiceKey(newProvider, newModel);
|
||||
|
||||
// Update provider or model
|
||||
await setItem(key, value.toString());
|
||||
if (key === 'ttsProvider') {
|
||||
setTTSProvider(value as string);
|
||||
} 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('');
|
||||
}
|
||||
const restoredVoice = savedVoices[voiceKey] || '';
|
||||
await updateAppConfig({
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigRow>);
|
||||
}
|
||||
else if (key === 'savedVoices') {
|
||||
setSavedVoices(value as SavedVoices);
|
||||
await setItem('savedVoices', JSON.stringify(value));
|
||||
const newSavedVoices = value as SavedVoices;
|
||||
await updateAppConfig({
|
||||
savedVoices: newSavedVoices,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await setItem(key, value.toString());
|
||||
switch (key) {
|
||||
case 'apiKey':
|
||||
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;
|
||||
}
|
||||
await updateAppConfig({
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
} as Partial<AppConfigRow>);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating config key ${key}:`, error);
|
||||
console.error(`Error updating config key ${String(key)}:`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
@ -388,6 +185,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
voice,
|
||||
skipBlank,
|
||||
epubTheme,
|
||||
smartSentenceSplitting,
|
||||
headerMargin,
|
||||
footerMargin,
|
||||
leftMargin,
|
||||
|
|
@ -399,7 +197,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
updateConfig,
|
||||
updateConfigKey,
|
||||
isLoading,
|
||||
isDBReady
|
||||
isDBReady,
|
||||
pdfHighlightEnabled
|
||||
}}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
|
|
@ -417,4 +216,4 @@ export function useConfig() {
|
|||
throw new Error('useConfig must be used within a ConfigProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,6 @@ interface DocumentContextType {
|
|||
removeHTMLDocument: (id: string) => Promise<void>;
|
||||
isHTMLLoading: boolean;
|
||||
|
||||
refreshPDFs: () => Promise<void>;
|
||||
refreshEPUBs: () => Promise<void>;
|
||||
refreshHTML: () => Promise<void>;
|
||||
|
||||
clearPDFs: () => Promise<void>;
|
||||
clearEPUBs: () => Promise<void>;
|
||||
clearHTML: () => Promise<void>;
|
||||
|
|
@ -42,7 +38,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
addDocument: addPDFDocument,
|
||||
removeDocument: removePDFDocument,
|
||||
isLoading: isPDFLoading,
|
||||
refresh: refreshPDFs,
|
||||
clearDocuments: clearPDFs
|
||||
} = usePDFDocuments();
|
||||
|
||||
|
|
@ -51,7 +46,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
addDocument: addEPUBDocument,
|
||||
removeDocument: removeEPUBDocument,
|
||||
isLoading: isEPUBLoading,
|
||||
refresh: refreshEPUBs,
|
||||
clearDocuments: clearEPUBs
|
||||
} = useEPUBDocuments();
|
||||
|
||||
|
|
@ -60,7 +54,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
addDocument: addHTMLDocument,
|
||||
removeDocument: removeHTMLDocument,
|
||||
isLoading: isHTMLLoading,
|
||||
refresh: refreshHTML,
|
||||
clearDocuments: clearHTML
|
||||
} = useHTMLDocuments();
|
||||
|
||||
|
|
@ -78,9 +71,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
addHTMLDocument,
|
||||
removeHTMLDocument,
|
||||
isHTMLLoading,
|
||||
refreshPDFs,
|
||||
refreshEPUBs,
|
||||
refreshHTML,
|
||||
clearPDFs,
|
||||
clearEPUBs,
|
||||
clearHTML
|
||||
|
|
|
|||
|
|
@ -8,19 +8,20 @@ import {
|
|||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
RefObject,
|
||||
RefObject
|
||||
} 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 { setLastDocumentLocation } from '@/utils/indexedDB';
|
||||
import { SpineItem } from 'epubjs/types/section';
|
||||
import type { 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 { useConfig } from './ConfigContext';
|
||||
import { combineAudioChunks } from '@/utils/audio';
|
||||
import { withRetry } from '@/utils/audio';
|
||||
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
|
||||
|
||||
interface EPUBContextType {
|
||||
currDocData: ArrayBuffer | undefined;
|
||||
|
|
@ -31,7 +32,8 @@ interface EPUBContextType {
|
|||
setCurrentDocument: (id: string) => Promise<void>;
|
||||
clearCurrDoc: () => void;
|
||||
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>;
|
||||
renditionRef: RefObject<Rendition | undefined>;
|
||||
tocRef: RefObject<NavItem[]>;
|
||||
|
|
@ -43,6 +45,81 @@ interface EPUBContextType {
|
|||
|
||||
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
|
||||
* Manages the state and operations for EPUB document handling
|
||||
|
|
@ -59,12 +136,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
voiceSpeed,
|
||||
voice,
|
||||
ttsProvider,
|
||||
ttsModel,
|
||||
ttsInstructions,
|
||||
smartSentenceSplitting,
|
||||
} = useConfig();
|
||||
// Current document state
|
||||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||
const [currDocName, setCurrDocName] = useState<string>();
|
||||
const [currDocText, setCurrDocText] = useState<string>();
|
||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
||||
const [isAudioCombining] = useState(false);
|
||||
|
||||
// Add new refs
|
||||
const bookRef = useRef<Book | null>(null);
|
||||
|
|
@ -98,7 +178,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
*/
|
||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
const doc = await indexedDBService.getEPUBDocument(id);
|
||||
const doc = await getEpubDocument(id);
|
||||
if (doc) {
|
||||
console.log('Retrieved document size:', doc.size);
|
||||
console.log('Retrieved ArrayBuffer size:', doc.data.byteLength);
|
||||
|
|
@ -139,8 +219,23 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
return '';
|
||||
}
|
||||
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);
|
||||
|
||||
return textContent;
|
||||
|
|
@ -148,7 +243,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
console.error('Error extracting EPUB text:', error);
|
||||
return '';
|
||||
}
|
||||
}, [setTTSText]);
|
||||
}, [setTTSText, smartSentenceSplitting]);
|
||||
|
||||
/**
|
||||
* Extracts text content from the entire EPUB book
|
||||
|
|
@ -197,21 +292,43 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
const createFullAudioBook = 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,
|
||||
providedBookId?: string,
|
||||
format: 'mp3' | 'm4b' = 'mp3'
|
||||
): Promise<ArrayBuffer> => {
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const sections = await extractBookText();
|
||||
if (!sections.length) throw new Error('No text content found in book');
|
||||
|
||||
// Calculate total length for accurate progress tracking
|
||||
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
|
||||
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
|
||||
let processedLength = 0;
|
||||
let currentTime = 0;
|
||||
let bookId: string = providedBookId || '';
|
||||
|
||||
// Get TOC for chapter titles
|
||||
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
|
||||
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
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
// Check for abort at the start of iteration
|
||||
if (signal?.aborted) {
|
||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
||||
return partialBuffer;
|
||||
console.log('Generation cancelled by user');
|
||||
if (bookId) {
|
||||
return bookId; // Return bookId with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
const section = sections[i];
|
||||
const trimmedText = section.text.trim();
|
||||
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 {
|
||||
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 () => {
|
||||
// Check for abort before starting TTS request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
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: trimmedText,
|
||||
voice: voice,
|
||||
speed: voiceSpeed,
|
||||
format: format === 'm4b' ? 'aac' : 'mp3',
|
||||
}),
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal
|
||||
});
|
||||
|
||||
|
|
@ -276,12 +421,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
return buffer;
|
||||
},
|
||||
{
|
||||
maxRetries: 2,
|
||||
initialDelay: 5000,
|
||||
maxDelay: 10000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
retryOptions
|
||||
);
|
||||
|
||||
// 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 (!chapterTitle) {
|
||||
chapterTitle = `Unknown Section - ${i + 1}`;
|
||||
chapterTitle = `Chapter ${i + 1}`;
|
||||
}
|
||||
|
||||
console.log('Processed audiobook chapter title:', chapterTitle);
|
||||
audioChunks.push({
|
||||
buffer: audioBuffer,
|
||||
title: chapterTitle,
|
||||
startTime: currentTime
|
||||
// Check for abort before sending to server
|
||||
if (signal?.aborted) {
|
||||
console.log('Generation cancelled before saving chapter');
|
||||
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 silence between sections
|
||||
const silenceBuffer = new ArrayBuffer(48000);
|
||||
audioChunks.push({
|
||||
buffer: silenceBuffer,
|
||||
startTime: currentTime + (audioBuffer.byteLength / 48000)
|
||||
});
|
||||
if (convertResponse.status === 499) {
|
||||
throw new Error('cancelled');
|
||||
}
|
||||
|
||||
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;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('TTS request aborted');
|
||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
||||
return partialBuffer;
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
console.log('TTS request aborted, returning partial progress');
|
||||
if (bookId) {
|
||||
return bookId; // Return with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
||||
return bookId;
|
||||
} catch (error) {
|
||||
console.error('Error creating audiobook:', 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) => {
|
||||
bookRef.current = rendition.book;
|
||||
|
|
@ -387,6 +717,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
clearCurrDoc,
|
||||
extractPageText,
|
||||
createFullAudioBook,
|
||||
regenerateChapter,
|
||||
bookRef,
|
||||
renditionRef,
|
||||
tocRef,
|
||||
|
|
@ -405,6 +736,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
clearCurrDoc,
|
||||
extractPageText,
|
||||
createFullAudioBook,
|
||||
regenerateChapter,
|
||||
handleLocationChanged,
|
||||
setRendition,
|
||||
isAudioCombining,
|
||||
|
|
@ -429,4 +761,4 @@ export function useEPUB() {
|
|||
throw new Error('useEPUB must be used within an EPUBProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,8 @@ import {
|
|||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { indexedDBService } from '@/utils/indexedDB';
|
||||
import { getHtmlDocument } from '@/lib/dexie';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { combineAudioChunks, withRetry } from '@/utils/audio';
|
||||
|
||||
interface HTMLContextType {
|
||||
currDocData: string | undefined;
|
||||
|
|
@ -19,8 +17,6 @@ interface HTMLContextType {
|
|||
currDocText: string | undefined;
|
||||
setCurrentDocument: (id: string) => Promise<void>;
|
||||
clearCurrDoc: () => void;
|
||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
|
||||
isAudioCombining: boolean;
|
||||
}
|
||||
|
||||
const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
||||
|
|
@ -33,13 +29,12 @@ const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
|||
*/
|
||||
export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||
const { setText: setTTSText, stop } = useTTS();
|
||||
const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider } = useConfig();
|
||||
|
||||
// Current document state
|
||||
const [currDocData, setCurrDocData] = useState<string>();
|
||||
const [currDocName, setCurrDocName] = useState<string>();
|
||||
const [currDocText, setCurrDocText] = useState<string>();
|
||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
||||
|
||||
|
||||
/**
|
||||
* 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> => {
|
||||
try {
|
||||
const doc = await indexedDBService.getHTMLDocument(id);
|
||||
const doc = await getHtmlDocument(id);
|
||||
if (doc) {
|
||||
setCurrDocName(doc.name);
|
||||
setCurrDocData(doc.data);
|
||||
|
|
@ -73,81 +68,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, [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(() => ({
|
||||
currDocData,
|
||||
|
|
@ -155,16 +76,12 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
|||
currDocText,
|
||||
setCurrentDocument,
|
||||
clearCurrDoc,
|
||||
createFullAudioBook,
|
||||
isAudioCombining,
|
||||
}), [
|
||||
currDocData,
|
||||
currDocName,
|
||||
currDocText,
|
||||
setCurrentDocument,
|
||||
clearCurrDoc,
|
||||
createFullAudioBook,
|
||||
isAudioCombining,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -22,20 +22,24 @@ import {
|
|||
useCallback,
|
||||
useMemo,
|
||||
RefObject,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { indexedDBService } from '@/utils/indexedDB';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
import { getPdfDocument } from '@/lib/dexie';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { processTextToSentences } from '@/lib/nlp';
|
||||
import { withRetry } from '@/utils/audio';
|
||||
import {
|
||||
extractTextFromPDF,
|
||||
highlightPattern,
|
||||
clearHighlights,
|
||||
handleTextClick,
|
||||
} from '@/utils/pdf';
|
||||
} from '@/lib/pdf';
|
||||
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import { combineAudioChunks, withRetry } from '@/utils/audio';
|
||||
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
|
||||
|
||||
/**
|
||||
* Interface defining all available methods and properties in the PDF context
|
||||
|
|
@ -60,15 +64,19 @@ interface PDFContextType {
|
|||
pdfText: string,
|
||||
containerRef: RefObject<HTMLDivElement>,
|
||||
stopAndPlayFromIndex: (index: number) => void,
|
||||
isProcessing: boolean
|
||||
isProcessing: boolean,
|
||||
enableHighlight?: boolean
|
||||
) => 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;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const PDFContext = createContext<PDFContextType | undefined>(undefined);
|
||||
|
||||
const CONTINUATION_PREVIEW_CHARS = 600;
|
||||
|
||||
/**
|
||||
* PDFProvider Component
|
||||
*
|
||||
|
|
@ -82,10 +90,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const {
|
||||
setText: setTTSText,
|
||||
stop,
|
||||
currDocPageNumber: currDocPage,
|
||||
currDocPageNumber,
|
||||
currDocPages,
|
||||
setCurrDocPages,
|
||||
setIsEPUB
|
||||
setIsEPUB,
|
||||
registerVisualPageChangeHandler,
|
||||
} = useTTS();
|
||||
const {
|
||||
headerMargin,
|
||||
|
|
@ -97,6 +106,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
voiceSpeed,
|
||||
voice,
|
||||
ttsProvider,
|
||||
ttsModel,
|
||||
ttsInstructions,
|
||||
smartSentenceSplitting,
|
||||
} = useConfig();
|
||||
|
||||
// Current document state
|
||||
|
|
@ -104,7 +116,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const [currDocName, setCurrDocName] = useState<string>();
|
||||
const [currDocText, setCurrDocText] = useState<string>();
|
||||
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
|
||||
|
|
@ -126,22 +144,60 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
const loadCurrDocText = useCallback(async () => {
|
||||
try {
|
||||
if (!pdfDocument) return;
|
||||
const text = await extractTextFromPDF(pdfDocument, currDocPage, {
|
||||
|
||||
const margins = {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
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 === '') {
|
||||
setCurrDocText(text);
|
||||
setTTSText(text);
|
||||
setTTSText(text, {
|
||||
location: currDocPageNumber,
|
||||
nextLocation: nextPageNumber,
|
||||
nextText: nextText?.slice(0, CONTINUATION_PREVIEW_CHARS),
|
||||
});
|
||||
}
|
||||
} catch (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
|
||||
|
|
@ -151,7 +207,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
if (currDocData) {
|
||||
loadCurrDocText();
|
||||
}
|
||||
}, [currDocPage, currDocData, loadCurrDocText]);
|
||||
}, [currDocPageNumber, currDocData, loadCurrDocText]);
|
||||
|
||||
/**
|
||||
* 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> => {
|
||||
try {
|
||||
const doc = await indexedDBService.getDocument(id);
|
||||
const doc = await getPdfDocument(id);
|
||||
if (doc) {
|
||||
setCurrDocName(doc.name);
|
||||
setCurrDocData(doc.data);
|
||||
|
|
@ -182,6 +238,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
setCurrDocText(undefined);
|
||||
setCurrDocPages(undefined);
|
||||
setPdfDocument(undefined);
|
||||
pageTextCacheRef.current.clear();
|
||||
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
|
||||
* @param {Function} onProgress - Callback for progress updates
|
||||
* @param {AbortSignal} signal - Optional signal for cancellation
|
||||
* @param {string} format - Optional format for the audiobook ('mp3' or 'm4b')
|
||||
* @returns {Promise<ArrayBuffer>} The complete audiobook as an ArrayBuffer
|
||||
* @param {Function} onChapterComplete - Optional callback for when a chapter completes
|
||||
* @returns {Promise<string>} The bookId for the generated audiobook
|
||||
*/
|
||||
const createFullAudioBook = 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,
|
||||
providedBookId?: string,
|
||||
format: 'mp3' | 'm4b' = 'mp3'
|
||||
): Promise<ArrayBuffer> => {
|
||||
): Promise<string> => {
|
||||
try {
|
||||
if (!pdfDocument) {
|
||||
throw new Error('No PDF document loaded');
|
||||
|
|
@ -207,16 +266,20 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
let totalLength = 0;
|
||||
|
||||
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
||||
const text = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||
header: headerMargin,
|
||||
footer: footerMargin,
|
||||
left: leftMargin,
|
||||
right: rightMargin
|
||||
});
|
||||
const trimmedText = text.trim();
|
||||
const trimmedText = rawText.trim();
|
||||
if (trimmedText) {
|
||||
textPerPage.push(trimmedText);
|
||||
totalLength += trimmedText.length;
|
||||
const processedText = smartSentenceSplitting
|
||||
? 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');
|
||||
}
|
||||
|
||||
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
|
||||
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
|
||||
for (let i = 0; i < textPerPage.length; i++) {
|
||||
// Check for abort at the start of iteration
|
||||
if (signal?.aborted) {
|
||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
||||
return partialBuffer;
|
||||
console.log('Generation cancelled by user');
|
||||
if (bookId) {
|
||||
return bookId; // Return bookId with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
|
||||
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 {
|
||||
const audioBuffer = await withRetry(
|
||||
async () => {
|
||||
// Check for abort before starting TTS request
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
|
||||
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,
|
||||
voice: voice,
|
||||
speed: voiceSpeed,
|
||||
format: format === 'm4b' ? 'aac' : 'mp3'
|
||||
}),
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal
|
||||
});
|
||||
|
||||
|
|
@ -265,51 +379,251 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
return buffer;
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 5000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
retryOptions
|
||||
);
|
||||
|
||||
audioChunks.push({
|
||||
buffer: audioBuffer,
|
||||
title: `Page ${i + 1}`,
|
||||
startTime: currentTime
|
||||
const chapterTitle = `Page ${i + 1}`;
|
||||
|
||||
// Check for abort before sending to server
|
||||
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)
|
||||
const silenceBuffer = new ArrayBuffer(48000);
|
||||
audioChunks.push({
|
||||
buffer: silenceBuffer,
|
||||
startTime: currentTime + (audioBuffer.byteLength / 48000)
|
||||
});
|
||||
if (convertResponse.status === 499) {
|
||||
throw new Error('cancelled');
|
||||
}
|
||||
|
||||
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;
|
||||
onProgress((processedLength / totalLength) * 100);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('TTS request aborted');
|
||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
||||
return partialBuffer;
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||
console.log('TTS request aborted, returning partial progress');
|
||||
if (bookId) {
|
||||
return bookId; // Return with partial progress
|
||||
}
|
||||
throw new Error('Audiobook generation cancelled');
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
||||
return bookId;
|
||||
} catch (error) {
|
||||
console.error('Error creating audiobook:', 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
|
||||
|
|
@ -318,6 +632,16 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
setIsEPUB(false);
|
||||
}, [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
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
|
|
@ -334,6 +658,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
handleTextClick,
|
||||
pdfDocument,
|
||||
createFullAudioBook,
|
||||
regenerateChapter,
|
||||
isAudioCombining,
|
||||
}),
|
||||
[
|
||||
|
|
@ -347,6 +672,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
|||
clearCurrDoc,
|
||||
pdfDocument,
|
||||
createFullAudioBook,
|
||||
regenerateChapter,
|
||||
isAudioCombining,
|
||||
]
|
||||
);
|
||||
|
|
@ -371,4 +697,4 @@ export function usePDF() {
|
|||
throw new Error('usePDF must be used within a PDFProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,20 @@ import { useAudioCache } from '@/hooks/audio/useAudioCache';
|
|||
import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
||||
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||||
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 { 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
|
||||
declare global {
|
||||
|
|
@ -49,18 +59,7 @@ declare global {
|
|||
/**
|
||||
* Interface defining all available methods and properties in the TTS context
|
||||
*/
|
||||
interface TTSContextType {
|
||||
// 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;
|
||||
|
||||
interface TTSContextType extends TTSPlaybackState {
|
||||
// Voice settings
|
||||
availableVoices: string[];
|
||||
|
||||
|
|
@ -71,16 +70,174 @@ interface TTSContextType {
|
|||
pause: () => void;
|
||||
stop: () => void;
|
||||
stopAndPlayFromIndex: (index: number) => void;
|
||||
setText: (text: string, shouldPause?: boolean) => void;
|
||||
setText: (text: string, options?: boolean | SetTextOptions) => void;
|
||||
setCurrDocPages: (num: number | undefined) => void;
|
||||
setSpeedAndRestart: (speed: number) => void;
|
||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||
setVoiceAndRestart: (voice: string) => void;
|
||||
skipToLocation: (location: string | number, shouldPause?: boolean) => void;
|
||||
registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation
|
||||
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
|
||||
registerLocationChangeHandler: (handler: (location: TTSLocation) => void) => void; // EPUB-only: Handles chapter navigation
|
||||
registerVisualPageChangeHandler: (handler: (location: TTSLocation) => void) => 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
|
||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||
|
||||
|
|
@ -106,15 +263,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
ttsInstructions: configTTSInstructions,
|
||||
updateConfigKey,
|
||||
skipBlank,
|
||||
smartSentenceSplitting,
|
||||
} = useConfig();
|
||||
|
||||
// Remove OpenAI client reference as it's no longer needed
|
||||
// Audio and voice management hooks
|
||||
const audioContext = useAudioContext();
|
||||
const audioCache = useAudioCache(25);
|
||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
||||
|
||||
// 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
|
||||
|
|
@ -122,10 +281,20 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
*
|
||||
* @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;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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
|
||||
const { id } = useParams();
|
||||
|
||||
|
|
@ -136,7 +305,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const [isEPUB, setIsEPUB] = 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 [currDocPages, setCurrDocPages] = useState<number>();
|
||||
|
||||
|
|
@ -155,6 +324,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const activeAbortControllers = useRef<Set<AbortController>>(new Set());
|
||||
// Track if we're restoring from a saved position
|
||||
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
|
||||
|
|
@ -178,20 +354,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const abortAudio = useCallback((clearPending = false) => {
|
||||
if (activeHowl) {
|
||||
activeHowl.stop();
|
||||
activeHowl.unload(); // Ensure Howl instance is fully cleaned up
|
||||
activeHowl.unload();
|
||||
setActiveHowl(null);
|
||||
}
|
||||
|
||||
if (clearPending) {
|
||||
// Abort all active TTS requests
|
||||
console.log('Aborting active TTS requests');
|
||||
activeAbortControllers.current.forEach(controller => {
|
||||
controller.abort();
|
||||
});
|
||||
activeAbortControllers.current.clear();
|
||||
// Clear any pending preload requests
|
||||
preloadRequests.current.clear();
|
||||
}
|
||||
|
||||
if (pageTurnTimeoutRef.current) {
|
||||
clearTimeout(pageTurnTimeoutRef.current);
|
||||
pageTurnTimeoutRef.current = null;
|
||||
}
|
||||
}, [activeHowl]);
|
||||
|
||||
/**
|
||||
|
|
@ -208,9 +386,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
* Works for both PDF pages and EPUB locations
|
||||
*
|
||||
* @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
|
||||
abortAudio();
|
||||
if (shouldPause) setIsPlaying(false);
|
||||
|
|
@ -230,7 +408,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
// Handle within current page bounds
|
||||
if (nextIndex < sentences.length && nextIndex >= 0) {
|
||||
console.log('isEPUB', isEPUB!);
|
||||
setCurrentIndex(nextIndex);
|
||||
return;
|
||||
}
|
||||
|
|
@ -294,9 +471,65 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
*
|
||||
* @param {string} text - The text to be processed
|
||||
*/
|
||||
const setText = useCallback((text: string, shouldPause = false) => {
|
||||
// Check for blank section first
|
||||
if (handleBlankSection(text)) return;
|
||||
const setText = useCallback((text: string, options?: boolean | SetTextOptions) => {
|
||||
const normalizedOptions: SetTextOptions = typeof options === 'boolean'
|
||||
? { 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
|
||||
const wasPlaying = isPlaying;
|
||||
|
|
@ -304,8 +537,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
abortAudio(true); // Clear pending requests since text is changing
|
||||
setIsProcessing(true); // Set processing state before text processing starts
|
||||
|
||||
console.log('Setting text:', text);
|
||||
processTextToSentencesLocal(text)
|
||||
processTextToSentencesLocal(workingText)
|
||||
.then(newSentences => {
|
||||
if (newSentences.length === 0) {
|
||||
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
|
||||
setSentences(newSentences);
|
||||
|
||||
|
||||
// Check if we have a pending restore index for PDF
|
||||
if (pendingRestoreIndex !== null && !isEPUB) {
|
||||
const restoreIndex = Math.min(pendingRestoreIndex, newSentences.length - 1);
|
||||
|
|
@ -325,7 +557,42 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
} else {
|
||||
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);
|
||||
|
||||
// Restore playback state if needed
|
||||
|
|
@ -344,7 +611,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
duration: 3000,
|
||||
});
|
||||
});
|
||||
}, [isPlaying, handleBlankSection, abortAudio, processTextToSentencesLocal, pendingRestoreIndex, isEPUB]);
|
||||
}, [isPlaying, handleBlankSection, abortAudio, processTextToSentencesLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting]);
|
||||
|
||||
/**
|
||||
* Toggles the playback state between playing and paused
|
||||
|
|
@ -412,19 +679,31 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
*/
|
||||
useEffect(() => {
|
||||
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)) {
|
||||
console.log(`Voice "${voice || '(empty)'}" not found in available voices. Using "${availableVoices[0]}"`);
|
||||
setVoice(availableVoices[0]);
|
||||
// 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
|
||||
*
|
||||
* @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> => {
|
||||
// Check if the audio is already cached
|
||||
|
|
@ -441,23 +720,37 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const controller = new AbortController();
|
||||
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(
|
||||
async () => {
|
||||
|
||||
const response = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'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
|
||||
}),
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify(reqBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
|
@ -467,12 +760,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
return response.arrayBuffer();
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 5000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
retryOptions
|
||||
);
|
||||
|
||||
// Remove the controller once the request is complete
|
||||
|
|
@ -532,7 +820,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
try {
|
||||
const audioBuffer = await getAudio(sentence);
|
||||
if (!audioBuffer) throw new Error('No audio data generated');
|
||||
|
||||
|
||||
// Convert to base64 data URI
|
||||
const bytes = new Uint8Array(audioBuffer);
|
||||
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
|
||||
*/
|
||||
const playSentenceWithHowl = useCallback(async (sentence: string) => {
|
||||
const playSentenceWithHowl = useCallback(async (sentence: string, sentenceIndex: number) => {
|
||||
if (!sentence) {
|
||||
console.log('No sentence to play');
|
||||
setIsProcessing(false);
|
||||
|
|
@ -591,13 +879,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
preload: true,
|
||||
pool: 5,
|
||||
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: () => {
|
||||
setIsProcessing(false);
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
}
|
||||
},
|
||||
onplayerror: function(this: Howl, error) {
|
||||
onplayerror: function (this: Howl, error) {
|
||||
console.warn('Howl playback error:', error);
|
||||
// Try to recover by forcing HTML5 audio mode
|
||||
if (this.state() === 'loaded') {
|
||||
|
|
@ -608,17 +918,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
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);
|
||||
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
// Calculate exponential backoff delay
|
||||
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
||||
console.log(`Retrying in ${delay}ms...`);
|
||||
|
||||
|
||||
// Wait for the delay
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
|
||||
// Try to create a new Howl instance
|
||||
const retryHowl = await createHowl(retryCount + 1);
|
||||
if (retryHowl) {
|
||||
|
|
@ -631,7 +941,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setActiveHowl(null);
|
||||
this.unload();
|
||||
setIsPlaying(false);
|
||||
|
||||
|
||||
toast.error('Audio loading failed after retries. Moving to next sentence...', {
|
||||
id: 'audio-load-error',
|
||||
style: {
|
||||
|
|
@ -640,18 +950,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
},
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
|
||||
advance();
|
||||
}
|
||||
},
|
||||
onend: function(this: Howl) {
|
||||
onend: function (this: Howl) {
|
||||
this.unload();
|
||||
setActiveHowl(null);
|
||||
if (pageTurnTimeoutRef.current) {
|
||||
clearTimeout(pageTurnTimeoutRef.current);
|
||||
pageTurnTimeoutRef.current = null;
|
||||
}
|
||||
if (isPlaying) {
|
||||
advance();
|
||||
}
|
||||
},
|
||||
onstop: function(this: Howl) {
|
||||
onstop: function (this: Howl) {
|
||||
setIsProcessing(false);
|
||||
this.unload();
|
||||
}
|
||||
|
|
@ -690,7 +1004,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
||||
|
||||
const playAudio = useCallback(async () => {
|
||||
const howl = await playSentenceWithHowl(sentences[currentIndex]);
|
||||
const howl = await playSentenceWithHowl(sentences[currentIndex], currentIndex);
|
||||
if (howl) {
|
||||
howl.play();
|
||||
}
|
||||
|
|
@ -762,6 +1076,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
// Cancel any ongoing request
|
||||
abortAudio();
|
||||
locationChangeHandlerRef.current = null;
|
||||
epubContinuationRef.current = null;
|
||||
continuationCarryRef.current.clear();
|
||||
setIsPlaying(false);
|
||||
setCurrentIndex(0);
|
||||
setSentences([]);
|
||||
|
|
@ -791,6 +1107,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const setSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||
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
|
||||
setIsProcessing(true);
|
||||
|
||||
|
|
@ -806,8 +1125,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
// Update config after state changes
|
||||
updateConfigKey('voiceSpeed', newSpeed).then(() => {
|
||||
setIsProcessing(false);
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying) {
|
||||
// Resume playback if it was playing before and this is the latest restart
|
||||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
});
|
||||
|
|
@ -821,6 +1140,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const setVoiceAndRestart = useCallback((newVoice: string) => {
|
||||
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
|
||||
setIsProcessing(true);
|
||||
|
||||
|
|
@ -836,8 +1158,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
// Update config after state changes
|
||||
updateConfigKey('voice', newVoice).then(() => {
|
||||
setIsProcessing(false);
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying) {
|
||||
// Resume playback if it was playing before and this is the latest restart
|
||||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
});
|
||||
|
|
@ -851,6 +1173,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||
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
|
||||
setIsProcessing(true);
|
||||
|
||||
|
|
@ -865,8 +1190,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
// Update config after state changes
|
||||
updateConfigKey('audioPlayerSpeed', newSpeed).then(() => {
|
||||
setIsProcessing(false);
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying) {
|
||||
// Resume playback if it was playing before and this is the latest restart
|
||||
if (wasPlaying && mySeq === restartSeqRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
});
|
||||
|
|
@ -897,6 +1222,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setVoiceAndRestart,
|
||||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
registerVisualPageChangeHandler,
|
||||
setIsEPUB
|
||||
}), [
|
||||
isPlaying,
|
||||
|
|
@ -921,6 +1247,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setVoiceAndRestart,
|
||||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
registerVisualPageChangeHandler,
|
||||
setIsEPUB
|
||||
]);
|
||||
|
||||
|
|
@ -937,7 +1264,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
getLastDocumentLocation(id as string).then(lastLocation => {
|
||||
if (lastLocation) {
|
||||
console.log('Setting last location:', lastLocation);
|
||||
|
||||
|
||||
if (isEPUB && locationChangeHandlerRef.current) {
|
||||
// For EPUB documents, use the location change handler
|
||||
locationChangeHandlerRef.current(lastLocation);
|
||||
|
|
@ -947,7 +1274,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
|
||||
const page = parseInt(pageStr, 10);
|
||||
const sentenceIndex = parseInt(sentenceIndexStr, 10);
|
||||
|
||||
|
||||
if (!isNaN(page) && !isNaN(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
|
||||
|
|
@ -994,7 +1321,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
/**
|
||||
* 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
|
||||
* @returns {TTSContextType} The TTS context value
|
||||
|
|
@ -1005,4 +1332,4 @@ export function useTTS() {
|
|||
throw new Error('useTTS must be used within a TTSProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,27 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
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 { useConfig } from '@/contexts/ConfigContext';
|
||||
|
||||
export function useEPUBDocuments() {
|
||||
const { isDBReady } = useConfig();
|
||||
const [documents, setDocuments] = useState<EPUBDocument[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const documents = useLiveQuery(
|
||||
() => db['epub-documents'].toArray(),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
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 isLoading = documents === undefined;
|
||||
|
||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||
const id = uuidv4();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
|
||||
console.log('Original file size:', file.size);
|
||||
console.log('ArrayBuffer size:', arrayBuffer.byteLength);
|
||||
|
||||
|
||||
const newDoc: EPUBDocument = {
|
||||
id,
|
||||
type: 'epub',
|
||||
|
|
@ -44,56 +31,23 @@ export function useEPUBDocuments() {
|
|||
data: arrayBuffer,
|
||||
};
|
||||
|
||||
try {
|
||||
await indexedDBService.addEPUBDocument(newDoc);
|
||||
setDocuments((prev) => [...prev, newDoc]);
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Failed to add EPUB document:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['epub-documents'].add(newDoc);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.removeEPUBDocument(id);
|
||||
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove EPUB document:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['epub-documents'].delete(id);
|
||||
}, []);
|
||||
|
||||
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> => {
|
||||
try {
|
||||
await indexedDBService.clearEPUBDocuments();
|
||||
setDocuments([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear EPUB documents:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['epub-documents'].clear();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documents,
|
||||
documents: documents ?? [],
|
||||
isLoading,
|
||||
addDocument,
|
||||
removeDocument,
|
||||
refresh,
|
||||
clearDocuments, // Add clearDocuments to return value
|
||||
clearDocuments,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, RefObject, useState } from 'react';
|
||||
import { debounce } from '@/utils/pdf';
|
||||
import { debounce } from '@/lib/pdf';
|
||||
|
||||
export function useEPUBResize(containerRef: RefObject<HTMLDivElement | null>) {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const getThemeStyles = (): IReactReaderStyle => {
|
|||
readerArea: {
|
||||
...baseStyle.readerArea,
|
||||
backgroundColor: colors.base,
|
||||
height: '100%',
|
||||
},
|
||||
titleArea: {
|
||||
...baseStyle.titleArea,
|
||||
|
|
|
|||
|
|
@ -1,32 +1,19 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
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 { useConfig } from '@/contexts/ConfigContext';
|
||||
|
||||
export function useHTMLDocuments() {
|
||||
const { isDBReady } = useConfig();
|
||||
const [documents, setDocuments] = useState<HTMLDocument[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const documents = useLiveQuery(
|
||||
() => db['html-documents'].toArray(),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
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 isLoading = documents === undefined;
|
||||
|
||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||
const id = uuidv4();
|
||||
|
|
@ -41,56 +28,23 @@ export function useHTMLDocuments() {
|
|||
data: content,
|
||||
};
|
||||
|
||||
try {
|
||||
await indexedDBService.addHTMLDocument(newDoc);
|
||||
setDocuments((prev) => [...prev, newDoc]);
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Failed to add HTML document:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['html-documents'].add(newDoc);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.removeHTMLDocument(id);
|
||||
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove HTML document:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['html-documents'].delete(id);
|
||||
}, []);
|
||||
|
||||
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> => {
|
||||
try {
|
||||
await indexedDBService.clearHTMLDocuments();
|
||||
setDocuments([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear HTML documents:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['html-documents'].clear();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documents,
|
||||
documents: documents ?? [],
|
||||
isLoading,
|
||||
addDocument,
|
||||
removeDocument,
|
||||
refresh,
|
||||
clearDocuments,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
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 { useConfig } from '@/contexts/ConfigContext';
|
||||
|
||||
export function usePDFDocuments() {
|
||||
const { isDBReady } = useConfig();
|
||||
const [documents, setDocuments] = useState<PDFDocument[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const documents = useLiveQuery(
|
||||
() => db['pdf-documents'].toArray(),
|
||||
[],
|
||||
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]);
|
||||
const isLoading = documents === undefined;
|
||||
|
||||
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 id = uuidv4();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
|
||||
const newDoc: PDFDocument = {
|
||||
id,
|
||||
type: 'pdf',
|
||||
|
|
@ -50,61 +28,23 @@ export function usePDFDocuments() {
|
|||
data: arrayBuffer,
|
||||
};
|
||||
|
||||
try {
|
||||
await indexedDBService.addDocument(newDoc);
|
||||
setDocuments((prev) => [...prev, newDoc]);
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Failed to add document:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['pdf-documents'].add(newDoc);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Removes a document from IndexedDB
|
||||
*
|
||||
* @param {string} id - The ID of the document to remove
|
||||
*/
|
||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.removeDocument(id);
|
||||
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove document:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['pdf-documents'].delete(id);
|
||||
}, []);
|
||||
|
||||
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> => {
|
||||
try {
|
||||
await indexedDBService.clearPDFDocuments();
|
||||
setDocuments([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear PDF documents:', error);
|
||||
throw error;
|
||||
}
|
||||
await db['pdf-documents'].clear();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documents,
|
||||
documents: documents ?? [],
|
||||
isLoading,
|
||||
addDocument,
|
||||
removeDocument,
|
||||
refresh,
|
||||
clearDocuments, // Add clearDocuments to return value
|
||||
clearDocuments,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { RefObject, useState, useEffect } from 'react';
|
||||
import { debounce } from '@/utils/pdf';
|
||||
import { debounce } from '@/lib/pdf';
|
||||
|
||||
interface UsePDFResizeResult {
|
||||
containerWidth: number;
|
||||
|
|
|
|||
|
|
@ -88,12 +88,12 @@ export function useTimeEstimation(): TimeEstimation {
|
|||
}
|
||||
}
|
||||
|
||||
lastProgressUpdateRef.current = currentTime;
|
||||
|
||||
if (shouldSkipUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastProgressUpdateRef.current = currentTime;
|
||||
|
||||
const history = progressHistoryRef.current;
|
||||
|
||||
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';
|
||||
|
||||
const MAX_BLOCK_LENGTH = 300;
|
||||
const MAX_BLOCK_LENGTH = 450;
|
||||
|
||||
/**
|
||||
* 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 rawSentences = doc.sentences().out('array') as string[];
|
||||
|
||||
// Merge multi-sentence dialogue enclosed in quotes into single items
|
||||
const mergedSentences = mergeQuotedDialogue(rawSentences);
|
||||
|
||||
let currentBlock = '';
|
||||
|
||||
for (const sentence of rawSentences) {
|
||||
for (const sentence of mergedSentences) {
|
||||
const trimmedSentence = sentence.trim();
|
||||
|
||||
|
||||
if (currentBlock && (currentBlock.length + trimmedSentence.length + 1) > MAX_BLOCK_LENGTH) {
|
||||
blocks.push(currentBlock.trim());
|
||||
currentBlock = trimmedSentence;
|
||||
|
|
@ -76,13 +79,8 @@ export const processTextToSentences = (text: string): string[] => {
|
|||
return [];
|
||||
}
|
||||
|
||||
if (text.length <= MAX_BLOCK_LENGTH) {
|
||||
// Single sentence preprocessing
|
||||
const cleanedText = preprocessSentenceForAudio(text);
|
||||
return [cleanedText];
|
||||
}
|
||||
|
||||
// Full text splitting into sentences
|
||||
// Always use the full splitting logic so we consistently respect
|
||||
// sentence boundaries and quoted dialogue, even for shorter texts.
|
||||
return splitIntoSentences(text);
|
||||
};
|
||||
|
||||
|
|
@ -150,4 +148,72 @@ export const processTextWithMapping = (text: string): {
|
|||
rawSentences,
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 @@
|
|||
/**
|
||||
* Utility functions for audio processing
|
||||
*/
|
||||
|
||||
interface AudioChunk {
|
||||
buffer: ArrayBuffer;
|
||||
title?: string;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
interface RetryOptions {
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffFactor?: number;
|
||||
}
|
||||
import type { TTSRetryOptions } from '@/types/tts';
|
||||
|
||||
/**
|
||||
* Executes a function with exponential backoff retry logic
|
||||
|
|
@ -23,7 +8,7 @@ interface RetryOptions {
|
|||
*/
|
||||
export const withRetry = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
options: TTSRetryOptions = {}
|
||||
): Promise<T> => {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
|
|
@ -39,7 +24,13 @@ export const withRetry = async <T>(
|
|||
return await operation();
|
||||
} catch (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) {
|
||||
break;
|
||||
}
|
||||
|
|
@ -55,78 +46,4 @@ export const withRetry = async <T>(
|
|||
}
|
||||
|
||||
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)",
|
||||
offbase: "var(--offbase)",
|
||||
accent: "var(--accent)",
|
||||
"secondary-accent": "var(--secondary-accent)",
|
||||
muted: "var(--muted)",
|
||||
},
|
||||
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 fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
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
|
||||
|
|
@ -14,19 +42,26 @@ export async function uploadFile(page: Page, filePath: string) {
|
|||
* Upload and display a document
|
||||
*/
|
||||
export async function uploadAndDisplay(page: Page, fileName: string) {
|
||||
// Upload the file
|
||||
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
|
||||
if (fileName.endsWith('.pdf')) {
|
||||
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
|
||||
const lower = fileName.toLowerCase();
|
||||
|
||||
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 });
|
||||
} 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();
|
||||
// Play the TTS by clicking the button
|
||||
await page.getByRole('button', { name: 'Play' }).click();
|
||||
|
||||
// Expect for buttons to be disabled
|
||||
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';
|
||||
});
|
||||
// Use resilient processing transition helper (tolerates fast completion)
|
||||
await expectProcessingTransition(page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,19 +88,285 @@ export async function playTTSAndWaitForASecond(page: Page, fileName: string) {
|
|||
// play for 1s
|
||||
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
|
||||
*/
|
||||
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
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click();
|
||||
await page.getByText('Deepinfra').click();
|
||||
// If running in CI, select the "Custom OpenAI-Like" model and "Deepinfra" provider
|
||||
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
|
||||
await page.getByRole('tab', { name: '🔑 API' }).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 { 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.beforeEach(async ({ 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 }) => {
|
||||
// Play TTS for the PDF document
|
||||
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
||||
|
||||
// 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 });
|
||||
// Pause TTS and verify paused state
|
||||
await pauseTTSAndVerify(page);
|
||||
});
|
||||
|
||||
test('plays and pauses TTS for an EPUB document', async ({ page }) => {
|
||||
// Play TTS for the EPUB document
|
||||
await playTTSAndWaitForASecond(page, 'sample.epub');
|
||||
|
||||
// Click pause to stop playback
|
||||
await page.getByRole('button', { name: 'Pause' }).click();
|
||||
// Pause TTS and verify paused state
|
||||
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
|
||||
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||
// Pause TTS and verify paused state
|
||||
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 { uploadFile, uploadAndDisplay, setupTest } from './helpers';
|
||||
import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers';
|
||||
|
||||
test.describe('Document Upload Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
|
@ -8,12 +8,17 @@ test.describe('Document Upload Tests', () => {
|
|||
|
||||
test('uploads a PDF document', async ({ page }) => {
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
|
|
@ -24,20 +29,101 @@ test.describe('Document Upload Tests', () => {
|
|||
// Should see the converting message
|
||||
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible();
|
||||
// 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 }) => {
|
||||
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.getByText('Sample PDF')).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays an EPUB document', async ({ page }) => {
|
||||
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();
|
||||
});
|
||||
|
||||
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