Merge pull request #62 from richardr1126/version1.0.0
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:
Richard Roberson 2025-11-16 21:22:27 -07:00 committed by GitHub
commit d7a94a6cb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 7157 additions and 3021 deletions

View file

@ -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

View file

@ -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
View file

@ -7,28 +7,69 @@
[![Discussions](https://img.shields.io/badge/Discussions-Ask%20a%20Question-blue)](../../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/)

View file

@ -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:
- 12 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.

View file

@ -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",

View file

@ -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. */

View file

@ -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: {}

View 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 }
);
}
}

View 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 }
);
}
}

View file

@ -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',
},
});
}

View file

@ -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) {

View file

@ -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);

View file

@ -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 }
);
}

View file

@ -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

View file

@ -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} />
</>
);

View file

@ -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 */
}

View file

@ -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} />
</>
);

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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} />
</>
);

View 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 &quot;Start Generation&quot; 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}
/>
</>
);
}

View file

@ -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}
>

View file

@ -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)}

View file

@ -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>
);
}

View file

@ -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'}

View file

@ -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
View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}
}

View 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>
);
}

View file

@ -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>&bull;</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>

View file

@ -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"

View 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>
);
}

View file

@ -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">

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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>

View file

@ -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 */}

View file

@ -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>
);
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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 (

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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,
};
}

View file

@ -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);

View file

@ -32,6 +32,7 @@ export const getThemeStyles = (): IReactReaderStyle => {
readerArea: {
...baseStyle.readerArea,
backgroundColor: colors.base,
height: '100%',
},
titleArea: {
...baseStyle.titleArea,

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -1,5 +1,5 @@
import { RefObject, useState, useEffect } from 'react';
import { debounce } from '@/utils/pdf';
import { debounce } from '@/lib/pdf';
interface UsePDFResizeResult {
containerWidth: number;

View file

@ -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
View 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() };
}

View file

@ -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, WizardLMs).
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
View 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);
};
}

View 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 12 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
View 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;
}

View file

@ -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
View 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';
}

View file

@ -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);
}
}
};

View file

@ -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);
}

View file

@ -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
View 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;
};

View file

@ -16,6 +16,7 @@ export default {
base: "var(--base)",
offbase: "var(--offbase)",
accent: "var(--accent)",
"secondary-accent": "var(--secondary-accent)",
muted: "var(--muted)",
},
animation: {

View 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
View 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
View 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
View 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
View 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

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
This is an unsupported file type used for upload rejection tests.

99
tests/folders.spec.ts Normal file
View 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);
});
});

View file

@ -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
View 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);
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});