diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 6c2f3e8..3ae9bcf 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 57f41ef..52a26f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index c346754..84d5450 100644 --- a/README.md +++ b/README.md @@ -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** +
+ + +### ๐Ÿ†• What's New in v1.0.0 + + + +- ๐Ÿง  **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. + +
## ๐Ÿณ 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. + +
+ + +**Docker CPU** + + ```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 +
-volumes: - docstore: +
+ + +**Docker GPU** + + + +```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 +
+ +> **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/) diff --git a/docs/issues-to-components.md b/docs/issues-to-components.md deleted file mode 100644 index 861deaa..0000000 --- a/docs/issues-to-components.md +++ /dev/null @@ -1,100 +0,0 @@ -# OpenReader Issue Triage and Mapping - -Repository: https://github.com/richardr1126/OpenReader-WebUI/issues -Reviewed via gh at 2025-11-10. - -Summary of open items included: -- #59 Feature: Chapter-Based MP3 Export -- #48 Bug: Failed to export after complete render with Kokoro -- #47 Feature: Combine voices (Kokoro โ€œplusโ€ syntax) -- #44 Bug: Dialog not chunked together -- #40 Bug: PDF left/right extraction margins not working - -Global guardrails -- Streaming-first playback (replace Howler with HTMLAudioElement/MSE) -- Dexie DB replacing vanilla IndexedDB -- Single engine cut-over (no dual engines) -- Keep audiobook (m4b) and server-side sync - -Issue #59 โ€” Chapter-Based MP3 Export -Type: Feature -Hypothesis / intent: -- Users want one-mp3-per-chapter output (besides full-book m4b). -Ideas: -- Add chapterized MP3 pipeline that chunks by adapter โ€œchapterโ€ units. -- Provide ZIP export for many MP3 files (streamed). -API design: -- POST /api/audio/convert?mode=chapters&format=mp3 -> returns stream of a ZIP. -Logging to add: -- Chapter boundaries, byte sizes, cumulative progress in [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1). -Acceptance: -- A multi-chapter EPUB produces N mp3 parts named โ€œNN - Chapter Title.mp3โ€; total duration ~ sum of parts; ZIP streamed without timeouts in Docker. - -Issue #48 โ€” Failed export after complete render with Kokoro -Type: Bug (large-book export) -Observations: -- UI reaches 100% then resets, no download. -- Docker shows โ€œfin โ€ฆ undefinedโ€, likely final transfer problem (not TTS). -Likely root causes: -- Final m4b delivery uses single huge arrayBuffer; browser memory/timeout. -- Missing Content-Disposition/Range; no resumable download. -- Temp-file lifecycle cleanup racing with response. -Ideas for remediation: -- Serve final artifact as file on disk with streaming and Range: - - New endpoint: GET /api/audio/convert/download?bookId=โ€ฆ that streams file with Accept-Ranges. - - UI performs streamed download; no arrayBuffer buffering. -- Optionally support S3-compatible offload in future. -Touched modules: -- [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1) -Instrumentation to add: -- Book ID, file size on disk, stream chunk counts, and client-abort detection. -- Add explicit log/error surfaces at: - - [src/app/api/audio/convert/route.ts](src/app/api/audio/convert/route.ts:1) -Acceptance: -- 1โ€“2 GB m4b exports download in Docker without UI reset; bookId persists until deletion; status 206 Range works. - -Issue #47 โ€” Kokoro combined voices (โ€œ+โ€ syntax) -Type: Feature -Intent: -- Support voice strings like โ€œbf_emma+af_heartโ€ when provider is Kokoro/FastAPI or DeepInfra Kokoro. -Ideas for implementation: -- Allow free-form voice string entry and pass-through if not in known set. -- Validate known providers; if โ€œ+โ€ present, skip voice validation list. - -Logging: -- Emit provider, model, raw voice string in request (no PII). -Tests: -- โ€œa+bโ€ voices produce audible combined output; works via DeepInfra pass-through and Kokoro-FastAPI; voice dropdown offers โ€œCustom voiceโ€ฆโ€ input. - -Issue #44 โ€” Dialog not being chunked together -Type: Bug (NLP splitting) -Observations: -- Dialog lines split mid-quote; needs grouping. -Proposed improvements: -- Extend [splitIntoSentences()](src/utils/nlp.ts:34) to apply quote-aware grouping. -- When a sentence begins with an opening quote and the next ends with a closing quote, join them before MAX_BLOCK_LENGTH checks. -Ideas for remediation: -- Provide composable splitter strategy wrapping existing utility. -- Add โ€œdialog-preserveโ€ flag toggled in settings. -Logging to add: -- Count of quote-joined sentences; per-page example of before/after lengths. -Acceptance: -- Quoted dialog flows as single units unless exceeding MAX_BLOCK_LENGTH by > X%; regression-safe for non-dialog text. - -Issue #40 โ€” PDF left/right extraction margins not working -Type: Bug (PDF extraction) -Observations: -- Current filter uses transform[4]/[5] with width heuristics. Edge cases: missing width, skew, page scale differences. -Likely causes: -- Some pdf.js TextItem miss width; horizontal margins computed but not applied due to scale mismatch. -- Defaults in [src/contexts/ConfigContext.tsx](src/contexts/ConfigContext.tsx:216) set left/right to '0.0' on first run; UX confusion. -Remediation: -- Compute glyph bbox width when width absent using transform matrix. -- Normalize x to [0..1] by dividing by pageWidth; compare to margins reliably. -- Add visual debug overlay (dev mode) to draw margin boxes while extracting. -Code touchpoints: -- [src/utils/pdf.ts](src/utils/pdf.ts:60) extractTextFromPDF(): margin math and width fallback. -Diagnostics to add: -- Per-page: kept/filtered counts, extremes of x positions, computed margins. -Acceptance: -- Test PDFs show left/right trimming correctly; e2e highlight still robust. \ No newline at end of file diff --git a/docs/examples/docker-compose.yml b/examples/docker-compose.yml similarity index 100% rename from docs/examples/docker-compose.yml rename to examples/docker-compose.yml diff --git a/package.json b/package.json index 026ce35..eef14ff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index e654a15..5d20f5f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bbecc8..a7a9ec6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/api/audio/convert/chapter/route.ts b/src/app/api/audio/convert/chapter/route.ts new file mode 100644 index 0000000..5a43d47 --- /dev/null +++ b/src/app/api/audio/convert/chapter/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/audio/convert/chapters/route.ts b/src/app/api/audio/convert/chapters/route.ts new file mode 100644 index 0000000..76efd83 --- /dev/null +++ b/src/app/api/audio/convert/chapters/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/audio/convert/route.ts b/src/app/api/audio/convert/route.ts index e57dcd2..4c4a3f3 100644 --- a/src/app/api/audio/convert/route.ts +++ b/src/app/api/audio/convert/route.ts @@ -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 { +async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const ffprobe = spawn('ffprobe', [ '-i', filePath, @@ -21,11 +23,38 @@ async function getAudioDuration(filePath: string): Promise { ]); 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 { }); ffprobe.on('error', (err) => { + if (finished) return; + cleanup(); reject(err); }); }); } -async function runFFmpeg(args: string[]): Promise { +async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { return new Promise((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 { }); 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', + }, + }); } \ No newline at end of file diff --git a/src/app/api/documents/docx-to-pdf/route.ts b/src/app/api/documents/docx-to-pdf/route.ts index df3a382..de61165 100644 --- a/src/app/api/documents/docx-to-pdf/route.ts +++ b/src/app/api/documents/docx-to-pdf/route.ts @@ -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 { +async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise { 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 { + 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) { diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 679b0ea..f926e2f 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -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); diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index d15d164..a075d41 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -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 & { 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({ + maxSize: TTS_CACHE_MAX_SIZE_BYTES, + sizeCalculation: (value) => value.byteLength, + ttl: TTS_CACHE_TTL_MS, +}); + +type InflightEntry = { + promise: Promise; + controller: AbortController; + consumers: number; +}; + +const inflightRequests = new Map(); + +function sleep(ms: number) { + return new Promise((res) => setTimeout(res, ms)); +} + +async function fetchTTSBufferWithRetry( + openai: OpenAI, + createParams: ExtendedSpeechParams, + signal: AbortSignal +): Promise { + 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; + 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 } ); } diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 6dcbeac..60a7252 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -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 { } 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 diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index d3659a5..07becf3 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); + const [containerHeight, setContainerHeight] = useState('auto'); + const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width, 0 = max padding) + const [maxPadPx, setMaxPadPx] = useState(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 (
@@ -50,7 +106,7 @@ export default function EPUBPage() { 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" > @@ -63,39 +119,68 @@ export default function EPUBPage() { return ( <> -
-
-
- 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]" +
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" + > + + + + Documents + + } + title={isLoading ? 'Loadingโ€ฆ' : (currDocName || '')} + right={ +
+ 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} + /> + + + +
-

- {isLoading ? 'Loading...' : currDocName} -

-
+ } + /> +
+ {isLoading ? ( +
+ +
+ ) : ( +
+ +
+ )}
- {isLoading ? ( -
- -
- ) : ( - - )} + + ); diff --git a/src/app/globals.css b/src/app/globals.css index 83f97ec..10bc47f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 */ +} diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index a5100bc..8127fc0 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [containerHeight, setContainerHeight] = useState('auto'); + const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width) + const [maxPadPx, setMaxPadPx] = useState(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 (
@@ -48,7 +76,7 @@ export default function HTMLPage() { {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" > @@ -61,39 +89,52 @@ export default function HTMLPage() { return ( <> -
-
-
- {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]" - > - - - - Documents - - + +
-

- {isLoading ? 'Loading...' : currDocName} -

-
+ } + /> +
+ {isLoading ? ( +
+ +
+ ) : ( +
+ +
+ )}
- {isLoading ? ( -
- -
- ) : ( - - )} + ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 708d1f9..164a6f8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 }) { -
-
-
- {children} - +
+
+ {children} + +
+ {!isDev && ( +
+
- {!isDev &&
} -
+ )}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 2b74e42..286c7e5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
+
-

OpenReader WebUI

-

A bring your own text-to-speech api web interface for reading documents with high quality voices

-
- - -
+
+
+

OpenReader WebUI

+

+ Bring your own text-to-speech API. + Read & listen to PDF, EPUB & HTML documents with high quality voices. +

+
+
+
+
+ +
); } diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index fe3d6fd..b08fc88 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(true); const [zoomLevel, setZoomLevel] = useState(100); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); + const [containerHeight, setContainerHeight] = useState('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 (
@@ -61,7 +100,7 @@ export default function PDFViewerPage() { {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" > @@ -74,56 +113,60 @@ export default function PDFViewerPage() { return ( <> -
-
+
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" + > + + + + Documents + + } + title={isLoading ? 'Loadingโ€ฆ' : (currDocName || '')} + right={
- {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]" + + - {zoomLevel}% - -
- + + +
-

- {isLoading ? 'Loading...' : currDocName} -

-
+ } + /> +
+ {isLoading ? ( +
+ +
+ ) : ( + + )}
- {isLoading ? ( -
- -
- ) : ( - - )} + + ); diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx new file mode 100644 index 0000000..c36970c --- /dev/null +++ b/src/components/AudiobookExportModal.tsx @@ -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; // Returns bookId + onRegenerateChapter?: ( + chapterIndex: number, + bookId: string, + format: 'mp3' | 'm4b', + signal: AbortSignal + ) => Promise; +} + +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([]); + const [bookId, setBookId] = useState(null); + const [isCombining, setIsCombining] = useState(false); + const [isLoadingExisting, setIsLoadingExisting] = useState(false); + const [isRefreshingChapters, setIsRefreshingChapters] = useState(false); + const [currentChapter, setCurrentChapter] = useState(''); + const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b'); + const [regeneratingChapter, setRegeneratingChapter] = useState(null); + const abortControllerRef = useRef(null); + const [pendingDeleteChapter, setPendingDeleteChapter] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [errorMessage, setErrorMessage] = useState(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 ( + <> + setIsOpen(true)} + currentChapter={currentChapter} + totalChapters={documentType === 'epub' ? undefined : undefined} + completedChapters={chapters.filter(c => c.status === 'completed').length} + /> + + + setIsOpen(false)}> + +
+ + +
+
+ + + {isLoadingExisting ? ( +
+ +
+ ) : ( + <> +
+
+

Export Audiobook

+ {!isGenerating && ( +
+ {chapters.length === 0 && ( + setFormat(newFormat)} + disabled={chapters.length > 0} + > +
+ + {format.toUpperCase()} + + + + + + + + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + + M4B + + )} + + + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + + MP3 + + )} + + + +
+
+ )} + {chapters.length === 0 && ( + + )} + {showResumeButton && ( + + )} + {showResetButton && ( + + )} +
+ )} +
+ {showRegenerateHint && ( +
+

+ TTS audio for this chapter may be cached +
+ Change the TTS playback options or restart the server to force uncached regeneration +

+ +
+ )} + {/* Progress Info */} + {isGenerating && ( + c.status === 'completed').length} + cancelText="Cancel" + /> + )} + + {chapters.length > 0 && ( + <> +
+
+

Chapters

+ {isRefreshingChapters && } +
+ {displayChapters.map((chapter) => ( +
+
+ {chapter.status === 'completed' ? ( + + ) : onRegenerateChapter ? ( + + ) : ( + + )} +
+

+ {chapter.title} +

+

โ€ข

+

+ {chapter.status !== 'completed' && Missing โ€ข }{formatDuration(chapter.duration)} +

+
+
+
+ {((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && ( + + + + + + + {chapter.status === 'completed' && ( + <> + + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + + + )} + {regeneratingChapter === chapter.index && ( + + {({ active }) => ( + + )} + + )} + {onRegenerateChapter && !isGenerating && ( + + {({ active, disabled }) => ( + + )} + + )} + + {/* end of menu items */} + + + )} +
+
+ ))} +
+ + {bookId && !isGenerating && ( +
+ +
+ )} + + )} + + {chapters.length === 0 && !isGenerating && !isLoadingExisting && ( +
+

+ Click "Start Generation" to begin creating your audiobook. +
+ Individual chapters will appear here as they are generated. +

+
+ )} +
+ +
+ +
+ + )} +
+
+
+
+
+
+ {/* Confirm delete chapter */} + 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 */} + 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 */} + setErrorMessage(null)} + onConfirm={() => setErrorMessage(null)} + title="Operation Failed" + message={errorMessage || ''} + confirmText="Close" + cancelText="" + isDangerous={false} + /> + + ); +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index cd828d5..a460afb 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -33,6 +33,7 @@ export function ConfirmDialog({ -
+
@@ -60,7 +61,7 @@ export function ConfirmDialog({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + - setAudioFormat(format as 'mp3' | 'm4b')}> -
- - {audioFormat === 'mp3' ? 'MP3' : 'M4B (Audiobook)'} - - - - {audioFormats.map((format) => ( - - `relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}` - } - > - {format.name} - - ))} - -
-
-
+ {isDev && !html &&
+
}
@@ -299,7 +247,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { >
- + {selectedView.name} @@ -312,11 +260,11 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { leaveTo="opacity-0" > - {viewTypes.map((view) => ( + {viewTypeTextMapping.map((view) => ( - `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

} + {!html && ( +
+ +

+ Merge sentences across page or section breaks for smoother TTS. +

+
+ )} + {!epub && !html && ( +
+ +

+ Show visual highlighting in the PDF viewer while TTS is reading. +

+
+ )} {epub && (