feat(audiobook): add chapter-based export with UI and API

Introduce end-to-end chapterized audiobook generation with persistent
storage, resumable workflows, and MP3/M4B support.

API:
- add /api/audio/convert/chapter (GET/DELETE) for per-chapter ops
- add /api/audio/convert/chapters (GET/DELETE) for listing/reset
- enhance /api/audio/convert:
  - accept mp3|m4b, stream combined file, cache complete output
  - robust chapter indexing, docstore persistence, list concat
  - AbortSignal-aware ffmpeg/ffprobe, 499 on cancel

UI/UX:
- add AudiobookExportModal with progress, resume, regenerate, download
- add ProgressCard, enhance ProgressPopup (click-to-focus, richer info)
- add Header, ZoomControl; move TTSPlayer to sticky bottom bar
- redesign EPUB/HTML/PDF pages for full-height layout and controls
- add HomeContent; compact uploader variant; document list polish

TTS/Contexts:
- EPUB/PDF contexts now generate per-chapter to disk, return bookId
- support chapter regeneration; progress/cancel propagation
- pass provider/model/instructions; standardize MP3 TTS output
- HTML context updates for model/instructions

Styling:
- globals: overlay-dim, scrollbar styles, prism gradient utilities
- theme vars: secondary-accent, prism-gradient; tailwind color addition

Misc:
- fix PDF scale calc to use container height
- EPUB theme reader area fills height
- time estimation update cadence stab
- audio util passes format to combine endpoint
This commit is contained in:
Richard Roberson 2025-11-11 13:32:30 -07:00
parent e7ce1a34ce
commit 42665884d7
39 changed files with 2767 additions and 607 deletions

View file

@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server';
import { createReadStream, existsSync } from 'fs';
import { readFile, unlink } from 'fs/promises';
import { join } from 'path';
export async function GET(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
if (!bookId || !chapterIndexStr) {
return NextResponse.json(
{ error: 'Missing bookId or chapterIndex parameter' },
{ status: 400 }
);
}
const chapterIndex = parseInt(chapterIndexStr);
if (isNaN(chapterIndex)) {
return NextResponse.json(
{ error: 'Invalid chapterIndex parameter' },
{ status: 400 }
);
}
const docstoreDir = join(process.cwd(), 'docstore');
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
// Read metadata to get format
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
if (!existsSync(metadataPath)) {
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
}
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'));
const format = metadata.format || 'm4b';
const chapterPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
if (!existsSync(chapterPath)) {
return NextResponse.json({ error: 'Chapter file not found' }, { status: 404 });
}
// Stream the chapter file
const stream = createReadStream(chapterPath);
const readableWebStream = new ReadableStream({
start(controller) {
stream.on('data', (chunk) => {
controller.enqueue(chunk);
});
stream.on('end', () => {
controller.close();
});
stream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
const sanitizedTitle = metadata.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
return new NextResponse(readableWebStream, {
headers: {
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${sanitizedTitle}.${format}"`,
'Cache-Control': 'no-cache',
},
});
} catch (error) {
console.error('Error downloading chapter:', error);
return NextResponse.json(
{ error: 'Failed to download chapter' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
if (!bookId || !chapterIndexStr) {
return NextResponse.json(
{ error: 'Missing bookId or chapterIndex parameter' },
{ status: 400 }
);
}
const chapterIndex = parseInt(chapterIndexStr, 10);
if (isNaN(chapterIndex)) {
return NextResponse.json(
{ error: 'Invalid chapterIndex parameter' },
{ status: 400 }
);
}
const docstoreDir = join(process.cwd(), 'docstore');
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
// Read metadata to get format (if present)
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
// Delete the chapter audio file (try both formats just in case)
const chapterPathM4b = join(intermediateDir, `${chapterIndex}-chapter.m4b`);
const chapterPathMp3 = join(intermediateDir, `${chapterIndex}-chapter.mp3`);
if (existsSync(chapterPathM4b)) await unlink(chapterPathM4b).catch(() => {});
if (existsSync(chapterPathMp3)) await unlink(chapterPathMp3).catch(() => {});
// Delete metadata if present
if (existsSync(metadataPath)) {
await unlink(metadataPath).catch(() => {});
}
// Invalidate any combined "complete" files
const completeM4b = join(intermediateDir, `complete.m4b`);
const completeMp3 = join(intermediateDir, `complete.mp3`);
if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {});
if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting chapter:', error);
return NextResponse.json(
{ error: 'Failed to delete chapter' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from 'next/server';
import { readdir, readFile, rm } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
export async function GET(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
if (!bookId) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
const docstoreDir = join(process.cwd(), 'docstore');
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
if (!existsSync(intermediateDir)) {
return NextResponse.json({ chapters: [], exists: false });
}
// Read all chapter metadata
const files = await readdir(intermediateDir);
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
const chapters: Array<{
index: number;
title: string;
duration?: number;
status: 'completed' | 'error';
bookId: string;
format?: 'mp3' | 'm4b';
}> = [];
for (const metaFile of metaFiles) {
try {
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
chapters.push({
index: meta.index,
title: meta.title,
duration: meta.duration,
status: 'completed',
bookId,
format: meta.format || 'm4b'
});
} catch (error) {
console.error(`Error reading metadata file ${metaFile}:`, error);
}
}
// Sort chapters by index
chapters.sort((a, b) => a.index - b.index);
// Check if complete audiobook exists (either format)
const format = chapters[0]?.format || 'm4b';
const completePath = join(intermediateDir, `complete.${format}`);
const hasComplete = existsSync(completePath);
return NextResponse.json({
chapters,
exists: true,
hasComplete,
bookId
});
} catch (error) {
console.error('Error fetching chapters:', error);
return NextResponse.json(
{ error: 'Failed to fetch chapters' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
if (!bookId) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
const docstoreDir = join(process.cwd(), 'docstore');
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
// If directory doesn't exist, consider it already reset
if (!existsSync(intermediateDir)) {
return NextResponse.json({ success: true, existed: false });
}
// Recursively delete the entire audiobook directory
await rm(intermediateDir, { recursive: true, force: true });
return NextResponse.json({ success: true, existed: true });
} catch (error) {
console.error('Error resetting audiobook:', error);
return NextResponse.json(
{ error: 'Failed to reset audiobook' },
{ status: 500 }
);
}
}

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { spawn } from 'child_process';
import { writeFile, readFile, mkdir, unlink, rmdir, readdir } from 'fs/promises';
import { writeFile, readFile, mkdir, unlink, readdir } from 'fs/promises';
import { existsSync, createReadStream } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
@ -9,9 +9,11 @@ interface ConversionRequest {
chapterTitle: string;
buffer: number[];
bookId?: string;
format?: 'mp3' | 'm4b';
chapterIndex?: number;
}
async function getAudioDuration(filePath: string): Promise<number> {
async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> {
return new Promise((resolve, reject) => {
const ffprobe = spawn('ffprobe', [
'-i', filePath,
@ -21,11 +23,38 @@ async function getAudioDuration(filePath: string): Promise<number> {
]);
let output = '';
let finished = false;
const onAbort = () => {
if (finished) return;
finished = true;
try {
ffprobe.kill('SIGKILL');
} catch {}
reject(new Error('ABORTED'));
};
const cleanup = () => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.on('close', (code) => {
if (finished) return;
cleanup();
if (code === 0) {
const duration = parseFloat(output.trim());
resolve(duration);
@ -35,20 +64,44 @@ async function getAudioDuration(filePath: string): Promise<number> {
});
ffprobe.on('error', (err) => {
if (finished) return;
cleanup();
reject(err);
});
});
}
async function runFFmpeg(args: string[]): Promise<void> {
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', args);
let finished = false;
const onAbort = () => {
if (finished) return;
finished = true;
try {
ffmpeg.kill('SIGKILL');
} catch {}
reject(new Error('ABORTED'));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
ffmpeg.stderr.on('data', (data) => {
console.error(`ffmpeg stderr: ${data}`);
});
ffmpeg.on('close', (code) => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
if (code === 0) {
resolve();
} else {
@ -57,6 +110,9 @@ async function runFFmpeg(args: string[]): Promise<void> {
});
ffmpeg.on('error', (err) => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
reject(err);
});
});
@ -66,51 +122,84 @@ export async function POST(request: NextRequest) {
try {
// Parse the request body
const data: ConversionRequest = await request.json();
const format = data.format || 'm4b';
// Create temp directory if it doesn't exist
const tempDir = join(process.cwd(), 'temp');
if (!existsSync(tempDir)) {
await mkdir(tempDir);
// Use docstore directory
const docstoreDir = join(process.cwd(), 'docstore');
if (!existsSync(docstoreDir)) {
await mkdir(docstoreDir);
}
// Generate or use existing book ID
const bookId = data.bookId || randomUUID();
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
// Create intermediate directory
if (!existsSync(intermediateDir)) {
await mkdir(intermediateDir);
}
// Count existing files to determine chapter index
const files = await readdir(intermediateDir);
const wavFiles = files.filter(f => f.endsWith('.wav'));
const chapterIndex = wavFiles.length;
// Use provided chapter index or find the next available index robustly (handles gaps)
let chapterIndex: number;
if (data.chapterIndex !== undefined) {
chapterIndex = data.chapterIndex;
} else {
const files = await readdir(intermediateDir);
const indices = files
.map(f => f.match(/^(\d+)-chapter\.(m4b|mp3)$/))
.filter((m): m is RegExpMatchArray => Boolean(m))
.map(m => parseInt(m[1], 10))
.sort((a, b) => a - b);
// Find smallest non-negative integer not present
let next = 0;
for (const idx of indices) {
if (idx === next) {
next++;
} else if (idx > next) {
break;
}
}
chapterIndex = next;
}
// Write input file
const inputPath = join(intermediateDir, `${chapterIndex}-input.aac`);
const outputPath = join(intermediateDir, `${chapterIndex}.wav`);
// Write input file (MP3 from TTS)
const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`);
const chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
// Write the chapter audio to a temp file
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
// Convert to WAV from raw aac with consistent format
await runFFmpeg([
'-i', inputPath,
'-f', 'wav',
'-c:a', 'copy',
'-preset', 'ultrafast',
'-threads', '0',
outputPath
]);
if (format === 'mp3') {
// For MP3, re-encode to ensure proper headers and consistent format
await runFFmpeg([
'-y', // Overwrite output file without asking
'-i', inputPath,
'-c:a', 'libmp3lame',
'-b:a', '64k',
'-metadata', `title=${data.chapterTitle}`,
chapterOutputPath
], request.signal);
} else {
// Convert MP3 to M4B container with proper encoding and metadata
await runFFmpeg([
'-y', // Overwrite output file without asking
'-i', inputPath,
'-c:a', 'aac',
'-b:a', '64k',
'-metadata', `title=${data.chapterTitle}`,
'-f', 'mp4',
chapterOutputPath
], request.signal);
}
// Get the duration and save metadata
const duration = await getAudioDuration(outputPath);
const duration = await getAudioDuration(chapterOutputPath, request.signal);
await writeFile(metadataPath, JSON.stringify({
title: data.chapterTitle,
duration,
index: chapterIndex
index: chapterIndex,
format
}));
// Clean up input file
@ -123,9 +212,15 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json(
{ error: 'cancelled' },
{ status: 499 }
);
}
console.error('Error processing audio chapter:', error);
return NextResponse.json(
{ error: 'Failed to process audio chapter' },
{ error: 'Failed to process audio chapter' },
{ status: 500 }
);
}
@ -134,15 +229,13 @@ export async function POST(request: NextRequest) {
export async function GET(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
if (!bookId) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
const tempDir = join(process.cwd(), 'temp');
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
const outputPath = join(tempDir, `${bookId}.m4b`);
const metadataPath = join(tempDir, `${bookId}-metadata.txt`);
const listPath = join(tempDir, `${bookId}-list.txt`);
const docstoreDir = join(process.cwd(), 'docstore');
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
if (!existsSync(intermediateDir)) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
@ -151,21 +244,39 @@ export async function GET(request: NextRequest) {
// Read all chapter metadata
const files = await readdir(intermediateDir);
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
const chapters: { title: string; duration: number; index: number }[] = [];
const chapters: { title: string; duration: number; index: number; format: string }[] = [];
for (const metaFile of metaFiles) {
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
chapters.push(meta);
}
if (chapters.length === 0) {
return NextResponse.json({ error: 'No chapters found' }, { status: 404 });
}
// Sort chapters by index
chapters.sort((a, b) => a.index - b.index);
// Determine output format from existing chapter metadata to avoid mismatches
const chapterFormat = (chapters[0]?.format === 'mp3' || chapters[0]?.format === 'm4b') ? chapters[0].format : 'm4b';
if (requestedFormat && requestedFormat !== chapterFormat) {
console.warn(`Requested format ${requestedFormat} differs from chapter format ${chapterFormat}. Using ${chapterFormat}.`);
}
const format = chapterFormat;
const outputPath = join(intermediateDir, `complete.${format}`);
const metadataPath = join(intermediateDir, 'metadata.txt');
const listPath = join(intermediateDir, 'list.txt');
// Create chapter metadata file
// Check if combined file already exists
if (existsSync(outputPath)) {
// Stream the existing file
return streamFile(outputPath, format);
}
// Create chapter metadata file for M4B
const metadata: string[] = [];
let currentTime = 0;
// Calculate chapter timings based on actual durations
chapters.forEach((chapter) => {
const startMs = Math.floor(currentTime * 1000);
currentTime += chapter.duration;
@ -185,72 +296,86 @@ export async function GET(request: NextRequest) {
// Create list file for concat
await writeFile(
listPath,
chapters.map(c => `file '${join(intermediateDir, `${c.index}.wav`)}'`).join('\n')
chapters.map(c => `file '${join(intermediateDir, `${c.index}-chapter.${format}`)}'`).join('\n')
);
// Combine all files into a single M4B
await runFFmpeg([
'-f', 'concat',
'-safe', '0',
'-i', listPath,
'-i', metadataPath,
'-map_metadata', '1',
'-c:a', 'copy', // c:a is codec for audio and :a is stream specifier
//'-codec', 'wav',
//'-b:a', '192k',
//'-threads', '0', // Use maximum available threads
//'-movflags', '+faststart',
//'-preset', 'ultrafast', // Use fastest encoding preset
outputPath
if (format === 'mp3') {
// For MP3, re-encode to properly rebuild headers and duration metadata
// Using libmp3lame to ensure proper MP3 structure
await runFFmpeg([
'-f', 'concat',
'-safe', '0',
'-i', listPath,
'-c:a', 'libmp3lame',
'-b:a', '64k',
outputPath
], request.signal);
} else {
// Combine all files into a single M4B with chapter metadata
await runFFmpeg([
'-f', 'concat',
'-safe', '0',
'-i', listPath,
'-i', metadataPath,
'-map_metadata', '1',
'-c:a', 'copy',
'-f', 'mp4',
outputPath
], request.signal);
}
// Clean up temporary files (but keep the chapters and complete file)
await Promise.all([
unlink(metadataPath).catch(console.error),
unlink(listPath).catch(console.error)
]);
// Stream the file back to the client
const stream = createReadStream(outputPath);
// Clean up function
const cleanup = async () => {
try {
await Promise.all([
...chapters.map(c => unlink(join(intermediateDir, `${c.index}.wav`))),
...chapters.map(c => unlink(join(intermediateDir, `${c.index}.meta.json`))),
unlink(metadataPath),
unlink(listPath),
unlink(outputPath),
rmdir(intermediateDir)
]);
} catch (error) {
console.error('Cleanup error:', error);
}
};
// Clean up after streaming is complete
stream.on('end', cleanup);
const readableWebStream = new ReadableStream({
start(controller) {
stream.on('data', (chunk) => {
controller.enqueue(chunk);
});
stream.on('end', () => {
controller.close();
});
stream.on('error', (err) => {
controller.error(err);
});
},
});
return new NextResponse(readableWebStream, {
headers: {
'Content-Type': 'audio/mp4',
},
});
return streamFile(outputPath, format);
} catch (error) {
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
return NextResponse.json(
{ error: 'cancelled' },
{ status: 499 }
);
}
console.error('Error creating M4B:', error);
return NextResponse.json(
{ error: 'Failed to create M4B file' },
{ error: 'Failed to create M4B file' },
{ status: 500 }
);
}
}
// Helper function to stream file
function streamFile(filePath: string, format: string) {
const stream = createReadStream(filePath);
const readableWebStream = new ReadableStream({
start(controller) {
stream.on('data', (chunk) => {
controller.enqueue(chunk);
});
stream.on('end', () => {
controller.close();
});
stream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
stream.destroy();
}
});
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
return new NextResponse(readableWebStream, {
headers: {
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="audiobook.${format}"`,
'Cache-Control': 'no-cache',
},
});
}

View file

@ -6,18 +6,26 @@ import { useCallback, useEffect, useState } from 'react';
import { useEPUB } from '@/contexts/EPUBContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { EPUBViewer } from '@/components/EPUBViewer';
import { Button } from '@headlessui/react';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon } from '@/components/icons/Icons';
import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { DownloadIcon } from '@/components/icons/Icons';
export default function EPUBPage() {
const { id } = useParams();
const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB();
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { stop } = useTTS();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
const [maxPadPx, setMaxPadPx] = useState<number>(0);
const loadDocument = useCallback(async () => {
console.log('Loading new epub (from page.tsx)');
@ -43,6 +51,55 @@ 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',
onProgress: (progress: number) => void,
signal: AbortSignal
) => {
return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal);
}, [regenerateEPUBChapter]);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
@ -50,7 +107,7 @@ export default function EPUBPage() {
<Link
href="/"
onClick={() => clearCurrDoc()}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
@ -63,39 +120,68 @@ export default function EPUBPage() {
return (
<>
<div className="p-2 pb-2 border-b border-offbase">
<div className="flex flex-wrap items-center justify-between">
<div className="flex items-center gap-1">
<Link
href="/"
onClick={() => clearCurrDoc()}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
<Header
left={
<Link
href="/"
onClick={() => clearCurrDoc()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
>
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-3">
<ZoomControl
value={padPct}
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
min={0}
max={100}
/>
<button
onClick={() => setIsAudiobookModalOpen(true)}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open audiobook export"
title="Export Audiobook"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
<Button
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
<button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
aria-label="View Settings"
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open settings"
>
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
</Button>
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</button>
</div>
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
{isLoading ? 'Loading...' : currDocName}
</h1>
</div>
}
/>
<div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
<EPUBViewer className="h-full" />
</div>
)}
</div>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<EPUBViewer className="p-4" />
)}
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}
documentType="epub"
documentId={id as string}
onGenerateAudiobook={handleGenerateAudiobook}
onRegenerateChapter={handleRegenerateChapter}
/>
<TTSPlayer />
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

View file

@ -10,7 +10,13 @@ html.light {
--base: #f7fafc;
--offbase: #e2e8f0;
--accent: #ef4444;
--secondary-accent: #3b82f6;
--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: #60a5fa;
--muted: #a3a3a3;
--prism-gradient: linear-gradient(90deg,
#fca5a5,
#fb7185,
#f87171
);
}
html.ocean {
@ -28,7 +40,13 @@ html.ocean {
--base: #0f172a;
--offbase: #1e293b;
--accent: #38bdf8;
--secondary-accent: #22d3ee;
--muted: #94a3b8;
--prism-gradient: linear-gradient(90deg,
#7dd3fc,
#38bdf8,
#0ea5e9
);
}
html.forest {
@ -37,7 +55,13 @@ html.forest {
--base: #111a15;
--offbase: #1a2820;
--accent: #4ade80;
--secondary-accent: #22c55e;
--muted: #7c8f85;
--prism-gradient: linear-gradient(90deg,
#86efac,
#4ade80,
#22c55e
);
}
html.sunset {
@ -46,7 +70,13 @@ html.sunset {
--base: #2c1810;
--offbase: #3d1f14;
--accent: #ff6b6b;
--secondary-accent: #f59e0b;
--muted: #bc8f8f;
--prism-gradient: linear-gradient(90deg,
#fca5a5,
#fb7185,
#ff6b6b
);
}
html.sea {
@ -55,7 +85,13 @@ html.sea {
--base: #102c3d;
--offbase: #1a3c52;
--accent: #06b6d4;
--secondary-accent: #0ea5e9;
--muted: #7ca7c4;
--prism-gradient: linear-gradient(90deg,
#67e8f9,
#22d3ee,
#06b6d4
);
}
html.mint {
@ -64,7 +100,13 @@ html.mint {
--base: #132d27;
--offbase: #1c3d35;
--accent: #2dd4bf;
--secondary-accent: #10b981;
--muted: #75a99c;
--prism-gradient: linear-gradient(90deg,
#99f6e4,
#5eead4,
#2dd4bf
);
}
body {
@ -76,3 +118,75 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* App shell utility (fullscreen, vertical layout) */
.app-shell {
--header-height: 3.25rem;
}
/* Themed overlay using foreground color without relying on /opacity classes */
.overlay-dim {
background-color: color-mix(in srgb, var(--foreground), transparent 75%);
}
/* Scrollbar styling for better fullscreen experience */
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: var(--base);
}
*::-webkit-scrollbar-thumb {
background: var(--offbase);
border-radius: 6px;
border: 2px solid var(--base);
}
*::-webkit-scrollbar-thumb:hover {
background: var(--muted);
}
/* Subtle prism animated outline around list items (very light), matches the row's rounded-lg curve exactly */
.prism-outline {
position: relative;
border: 1px solid transparent;
/* do not set border-radius here; let the element's rounded-lg from Tailwind define the curve */
background:
linear-gradient(var(--card-fill, var(--offbase)), var(--card-fill, var(--offbase))) padding-box,
var(--prism-gradient) border-box;
background-clip: padding-box, border-box;
background-size: 300% 300%;
animation: prism-shift 8s linear infinite, prism-fade 3s ease-in-out infinite;
}
@keyframes prism-shift {
0% { background-position: 0% 50%; }
100% { background-position: 300% 50%; }
}
/* Gentle opacity pulse to simulate a "breath" without shadows spilling outside */
@keyframes prism-fade {
0% { opacity: 0.8; }
50% { opacity: 1.0; }
100% { opacity: 0.8; }
}
/* Static prism gradient divider (no animation) */
.prism-divider {
width: 100%;
height: 0.7px; /* use 2px then feather for a crisper center */
position: relative;
border: 0;
background: none;
opacity: 0.95;
}
.prism-divider::before {
content: '';
position: absolute;
inset: 0;
background: var(--prism-gradient);
/* Feather edges so they appear thinner */
mask: linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%);
-webkit-mask: linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%);
border-radius: 999px; /* subtle rounding to reinforce taper */
}

View file

@ -6,10 +6,12 @@ import { useCallback, useEffect, useState } from 'react';
import { useHTML } from '@/contexts/HTMLContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { HTMLViewer } from '@/components/HTMLViewer';
import { Button } from '@headlessui/react';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon } from '@/components/icons/Icons';
import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
import { ZoomControl } from '@/components/ZoomControl';
export default function HTMLPage() {
const { id } = useParams();
@ -18,6 +20,9 @@ export default function HTMLPage() {
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
const [maxPadPx, setMaxPadPx] = useState<number>(0);
const loadDocument = useCallback(async () => {
if (!isLoading) return;
@ -41,6 +46,29 @@ export default function HTMLPage() {
loadDocument();
}, [loadDocument]);
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
const compute = () => {
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
const ttsbar = document.querySelector('[data-app-ttsbar]') as HTMLElement | null;
const headerH = header ? header.getBoundingClientRect().height : 0;
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
const vh = window.innerHeight;
const h = Math.max(0, vh - headerH - ttsH);
setContainerHeight(`${h}px`);
// Adaptive minimum content width: allow some padding on narrow screens
const vw = window.innerWidth;
const desiredMin = 640;
const minContent = Math.min(desiredMin, Math.max(320, vw - 32));
const maxPad = Math.max(0, Math.floor((vw - minContent) / 2));
setMaxPadPx(maxPad);
};
compute();
window.addEventListener('resize', compute);
return () => window.removeEventListener('resize', compute);
}, []);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
@ -48,7 +76,7 @@ export default function HTMLPage() {
<Link
href="/"
onClick={() => {clearCurrDoc();}}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
@ -61,39 +89,52 @@ export default function HTMLPage() {
return (
<>
<div className="p-2 pb-2 border-b border-offbase">
<div className="flex flex-wrap items-center justify-between">
<div className="flex items-center gap-1">
<Link
href="/"
onClick={() => {clearCurrDoc();}}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
<Button
<Header
left={
<Link
href="/"
onClick={() => clearCurrDoc()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
>
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-3">
<ZoomControl
value={padPct}
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
min={0}
max={100}
/>
<button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
aria-label="View Settings"
className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open settings"
>
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
</Button>
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</button>
</div>
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
{isLoading ? 'Loading...' : currDocName}
</h1>
</div>
}
/>
<div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
<HTMLViewer className="h-full" />
</div>
)}
</div>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<HTMLViewer className="p-4" />
)}
<TTSPlayer />
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

View file

@ -4,7 +4,7 @@ import { Providers } from "@/app/providers";
import { Metadata } from "next";
import { Footer } from "@/components/Footer";
import { Toaster } from 'react-hot-toast';
import { Analytics } from "@vercel/analytics/next"
import { Analytics } from "@vercel/analytics/next";
export const metadata: Metadata = {
title: "OpenReader WebUI",
@ -56,14 +56,16 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</head>
<body className="antialiased">
<Providers>
<div className="min-h-screen bg-background p-4">
<div className="relative max-w-6xl mx-auto align-center">
<div className="bg-base rounded-lg shadow-lg">
{children}
<Analytics />
<div className="app-shell min-h-screen flex flex-col bg-background">
<main className="flex-1 flex flex-col">
{children}
<Analytics />
</main>
{!isDev && (
<div className="px-4 py-3">
<Footer />
</div>
{!isDev && <Footer />}
</div>
)}
</div>
<Toaster />
</Providers>

View file

@ -1,17 +1,27 @@
import { DocumentUploader } from '@/components/DocumentUploader';
import { DocumentList } from '@/components/doclist/DocumentList';
import { HomeContent } from '@/components/HomeContent';
import { SettingsModal } from '@/components/SettingsModal';
// Home page redesigned for fullscreen layout: hero + document area.
export default function Home() {
return (
<div className='p-3.5 sm:p-5'>
<div className="flex flex-col h-full w-full">
<SettingsModal />
<h1 className="text-xl font-bold text-center flex-grow">OpenReader WebUI</h1>
<p className="text-sm mt-1 text-center text-muted mb-5">A bring your own text-to-speech api web interface for reading documents with high quality voices</p>
<div className="flex flex-col items-center gap-5">
<DocumentUploader className='max-w-xl' />
<DocumentList />
</div>
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
<div className="max-w-5xl mx-auto">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
<p className="text-sm leading-relaxed max-w-prose text-foreground">
Bring your own text-to-speech API.
<span className="block font-medium">Read & listen to PDF, EPUB & HTML documents with high quality voices.</span>
</p>
</div>
</section>
<section className="flex-1 px-4 pb-8 overflow-auto">
<div className="max-w-5xl mx-auto">
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
<HomeContent />
</div>
</section>
</div>
);
}

View file

@ -7,9 +7,12 @@ import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import { Button } from '@headlessui/react';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon } from '@/components/icons/Icons';
import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
import { Header } from '@/components/Header';
import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import TTSPlayer from '@/components/player/TTSPlayer';
// Dynamic import for client-side rendering only
const PDFViewer = dynamic(
@ -22,12 +25,14 @@ const PDFViewer = dynamic(
export default function PDFViewerPage() {
const { id } = useParams();
const { setCurrentDocument, currDocName, clearCurrDoc } = usePDF();
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
const { stop } = useTTS();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(100);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const loadDocument = useCallback(async () => {
if (!isLoading) return; // Prevent calls when not loading new doc
@ -51,9 +56,44 @@ 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',
onProgress: (progress: number) => void,
signal: AbortSignal
) => {
return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal);
}, [regeneratePDFChapter]);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
@ -61,7 +101,7 @@ export default function PDFViewerPage() {
<Link
href="/"
onClick={() => {clearCurrDoc();}}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
@ -74,56 +114,60 @@ export default function PDFViewerPage() {
return (
<>
<div className="p-2 pb-2 border-b border-offbase">
<div className="flex flex-wrap items-center justify-between">
<Header
left={
<Link
href="/"
onClick={() => clearCurrDoc()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
>
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
}
title={isLoading ? 'Loading…' : (currDocName || '')}
right={
<div className="flex items-center gap-2">
<Link
href="/"
onClick={() => {clearCurrDoc();}}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
<button
onClick={() => setIsAudiobookModalOpen(true)}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open audiobook export"
title="Export Audiobook"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Documents
</Link>
<div className="bg-offbase px-2 py-0.5 rounded-full flex items-center gap-2">
<Button
onClick={handleZoomOut}
className="text-xs transform transition-transform duration-200 ease-in-out hover:scale-[1.45] hover:font-semibold hover:text-accent"
aria-label="Zoom out"
>
</Button>
<span className="text-xs">{zoomLevel}%</span>
<Button
onClick={handleZoomIn}
className="text-xs transform transition-transform duration-200 ease-in-out hover:scale-[1.45] hover:font-semibold hover:text-accent"
aria-label="Zoom in"
>
</Button>
</div>
<Button
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
<button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
aria-label="View Settings"
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Open settings"
>
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
</Button>
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
</button>
</div>
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
{isLoading ? 'Loading...' : currDocName}
</h1>
</div>
}
/>
<div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<PDFViewer zoomLevel={zoomLevel} />
)}
</div>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<PDFViewer zoomLevel={zoomLevel} />
)}
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}
documentType="pdf"
documentId={id as string}
onGenerateAudiobook={handleGenerateAudiobook}
onRegenerateChapter={handleRegenerateChapter}
/>
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

View file

@ -0,0 +1,733 @@
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';
interface AudiobookChapter {
index: number;
title: string;
duration?: number;
status: 'pending' | 'generating' | 'completed' | 'error';
bookId?: string;
format?: 'mp3' | 'm4b';
}
interface AudiobookExportModalProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
documentType: 'epub' | 'pdf';
documentId: string;
onGenerateAudiobook: (
onProgress: (progress: number) => void,
signal: AbortSignal,
onChapterComplete: (chapter: AudiobookChapter) => void,
format: 'mp3' | 'm4b'
) => Promise<string>; // Returns bookId
onRegenerateChapter?: (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal
) => Promise<AudiobookChapter>;
}
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<AudiobookChapter[]>([]);
const [bookId, setBookId] = useState<string | null>(null);
const [isCombining, setIsCombining] = useState(false);
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
const [currentChapter, setCurrentChapter] = useState<string>('');
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
const [regenerationProgress, setRegenerationProgress] = useState<number>(0);
const abortControllerRef = useRef<AbortController | null>(null);
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<AudiobookChapter | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
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: AudiobookChapter) => {
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: AudiobookChapter) => {
if (!onRegenerateChapter || !bookId) return;
setRegeneratingChapter(chapter.index);
setRegenerationProgress(0);
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,
(progress) => {
setRegenerationProgress(progress);
setProgress(progress);
},
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);
setRegenerationProgress(0);
setCurrentChapter('');
setProgress(0);
abortControllerRef.current = null;
// Refresh chapters to get updated data (soft refresh list only)
await fetchExistingChapters(true);
}
}, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters]);
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: AudiobookChapter) => {
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: AudiobookChapter[] =
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 button
const hasIncompleteOrMissing = displayChapters.some(c => c.status !== 'completed');
const showResumeButton = !isGenerating && chapters.length > 0 && (progress < 100 || hasIncompleteOrMissing);
// Do not render until storage/config is initialized
if (isLoading || !isDBReady) {
return null;
}
return (
<>
<ProgressPopup
isOpen={isGenerating && !isOpen}
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={handleCancel}
isProcessing={isCombining}
cancelText="Cancel"
operationType="audiobook"
onClick={() => setIsOpen(true)}
currentChapter={currentChapter}
totalChapters={documentType === 'epub' ? undefined : undefined}
completedChapters={chapters.filter(c => c.status === 'completed').length}
/>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-2xl transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
{isLoadingExisting ? (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
) : (
<>
<div className="space-y-4">
<div className="flex justify-between items-center gap-3">
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
{!isGenerating && (
<div className="flex items-center gap-2">
{chapters.length === 0 && (
<Listbox
value={format}
onChange={(newFormat) => setFormat(newFormat)}
disabled={chapters.length > 0}
>
<div className="relative">
<ListboxButton className="relative cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[100px]">
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute right-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
<ListboxOption
value="m4b"
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${
active ? 'bg-accent/10 text-accent' : 'text-foreground'
}`
}
>
{({ selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
M4B
</span>
)}
</ListboxOption>
<ListboxOption
value="mp3"
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${
active ? 'bg-accent/10 text-accent' : 'text-foreground'
}`
}
>
{({ selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
MP3
</span>
)}
</ListboxOption>
</ListboxOptions>
</Transition>
</div>
</Listbox>
)}
{chapters.length === 0 && (
<Button
onClick={handleStartGeneration}
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:opacity-95 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Start Generation
</Button>
)}
{showResumeButton && (
<>
<Button
onClick={handleStartGeneration}
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:opacity-95 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Resume
</Button>
<Button
onClick={() => setShowResetConfirm(true)}
disabled={isGenerating}
className="inline-flex justify-center rounded-lg bg-red-500 px-4 py-2 text-sm
font-medium text-background hover:opacity-95 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
title="Delete all generated chapters/pages for this document"
>
Reset
</Button>
</>
)}
</div>
)}
</div>
{/* Progress Info */}
{isGenerating && (
<ProgressCard
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={handleCancel}
isProcessing={isCombining}
operationType="audiobook"
currentChapter={currentChapter}
completedChapters={chapters.filter(c => c.status === 'completed').length}
cancelText="Cancel"
/>
)}
{chapters.length > 0 && (
<>
<div className={`space-y-2 max-h-96 overflow-y-auto ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`} aria-busy={isRefreshingChapters}>
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground">Chapters</h4>
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />}
</div>
{displayChapters.map((chapter) => (
<div
key={chapter.index}
className={`flex items-center justify-between p-2 sm:p-3 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
>
<div className="flex items-center space-x-3 flex-1">
{chapter.status === 'completed' ? (
<CheckCircleIcon className="h-5 w-5 text-accent" />
) : onRegenerateChapter ? (
<Button
onClick={() => handleRegenerateChapter(chapter)}
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
className="inline-flex items-center justify-center rounded-full bg-accent/10 text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] disabled:opacity-50 disabled:cursor-not-allowed"
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
</Button>
) : (
<ClockIcon className="h-5 w-5 text-muted" />
)}
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
{chapter.title}
</p>
<p className="text-xs text-muted">
{chapter.status !== 'completed' && <span className="text-warning">Missing </span>}Duration: {formatDuration(chapter.duration)}
</p>
</div>
</div>
<div className="flex items-center">
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
<Menu as="div" className="relative inline-block text-left">
<MenuButton
className="inline-flex items-center justify-center rounded-md p-1.5 hover:bg-background focus:outline-none focus-visible:ring-2 focus-visible:ring-accent text-muted hover:text-foreground transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
title="Chapter actions"
>
<DotsVerticalIcon className="h-5 w-5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems className="absolute right-0 mt-2 w-44 origin-top-right rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-10 p-1">
{onRegenerateChapter && !isGenerating && (
<MenuItem disabled={regeneratingChapter !== null}>
{({ active, disabled }) => (
<button
onClick={() => handleRegenerateChapter(chapter)}
disabled={disabled}
className={`${active ? 'bg-accent/10 text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed`}
title="Regenerate this chapter"
>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
<span>{regeneratingChapter === chapter.index ? `Regenerating (${Math.round(regenerationProgress)}%)` : 'Regenerate'}</span>
</button>
)}
</MenuItem>
)}
{chapter.status === 'completed' && (
<>
<MenuItem>
{({ active }) => (
<button
onClick={() => handleDownloadChapter(chapter)}
className={`${active ? 'bg-accent/10 text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
>
<DownloadIcon className="h-4 w-4" />
<span>Download</span>
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
onClick={() => setPendingDeleteChapter(chapter)}
className={`${active ? 'bg-accent/10 text-accent' : 'text-accent'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
title="Delete this chapter"
>
<XCircleIcon className="h-4 w-4" />
<span>Delete</span>
</button>
)}
</MenuItem>
</>
)}
</MenuItems>
</Transition>
</Menu>
)}
</div>
</div>
))}
</div>
{bookId && !isGenerating && (
<div className="pt-4 border-t border-offbase">
<Button
onClick={handleDownloadComplete}
disabled={isCombining}
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-4 py-3 text-sm
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
font-medium text-background hover:opacity-95 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
<DownloadIcon className="h-5 w-5" />
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
</Button>
</div>
)}
</>
)}
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
<div className="text-center py-8">
<p className="text-sm text-muted">
Click &quot;Start Generation&quot; to begin creating your audiobook.
<br />
Individual chapters will appear here as they are generated.
</p>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={() => setIsOpen(false)}
>
Close
</Button>
</div>
</>
)}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
{/* Confirm delete chapter */}
<ConfirmDialog
isOpen={pendingDeleteChapter !== null}
onClose={() => setPendingDeleteChapter(null)}
onConfirm={performDeleteChapter}
title="Delete Chapter"
message={pendingDeleteChapter ? `Delete "${pendingDeleteChapter.title}"? This will remove the audio and metadata for this chapter.` : ''}
confirmText="Delete"
cancelText="Cancel"
isDangerous
/>
{/* Confirm reset all */}
<ConfirmDialog
isOpen={showResetConfirm}
onClose={() => setShowResetConfirm(false)}
onConfirm={performResetAll}
title="Reset Audiobook"
message="Reset audiobook? This deletes all generated chapters/pages and any combined files. This cannot be undone."
confirmText="Reset"
cancelText="Cancel"
isDangerous
/>
{/* Error dialog replacing alerts */}
<ConfirmDialog
isOpen={errorMessage !== null}
onClose={() => setErrorMessage(null)}
onConfirm={() => setErrorMessage(null)}
title="Operation Failed"
message={errorMessage || ''}
confirmText="Close"
cancelText=""
isDangerous={false}
/>
</>
);
}

View file

@ -46,7 +46,7 @@ export function ConfirmDialog({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
@ -88,8 +88,8 @@ export function ConfirmDialog({
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
${isDangerous
? 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 hover:text-white'
: 'bg-accent text-white hover:bg-accent/90 focus-visible:ring-accent hover:text-background'
? 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent'
: 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent'
}`}
onClick={onConfirm}
>

View file

@ -1,13 +1,13 @@
'use client';
import { Fragment, useState, useRef, useCallback, useEffect } from 'react';
import { Fragment, useState, useCallback, useEffect } from 'react';
import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
import { useConfig, ViewType } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import { useEPUB } from '@/contexts/EPUBContext';
import { usePDF } from '@/contexts/PDFContext';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { useParams } from 'next/navigation';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -17,11 +17,6 @@ const viewTypes = [
{ id: 'scroll', name: 'Continuous Scroll' },
];
const audioFormats = [
{ id: 'mp3', name: 'MP3' },
{ id: 'm4b', name: 'M4B' },
];
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
isOpen: boolean,
setIsOpen: (isOpen: boolean) => void,
@ -38,18 +33,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
rightMargin,
updateConfigKey
} = useConfig();
const { createFullAudioBook, isAudioCombining } = useEPUB();
const { createFullAudioBook: createPDFAudioBook, isAudioCombining: isPDFAudioCombining } = usePDF();
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const [isGenerating, setIsGenerating] = useState(false);
const [audioFormat, setAudioFormat] = useState<'mp3' | 'm4b'>('mp3');
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
const { id } = useParams();
const [localMargins, setLocalMargins] = useState({
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin
});
const abortControllerRef = useRef<AbortController | null>(null);
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
// Sync local margins with global state
@ -79,61 +72,42 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
}
};
const handleStartGeneration = useCallback(async () => {
setIsGenerating(true);
setProgress(0);
abortControllerRef.current = new AbortController();
try {
const audioBuffer = epub ? await createFullAudioBook(
(progress) => setProgress(progress),
abortControllerRef.current.signal,
audioFormat
) : await createPDFAudioBook(
(progress) => setProgress(progress),
abortControllerRef.current.signal,
audioFormat
);
// Create and trigger download
const mimeType = audioFormat === 'mp3' ? 'audio/mp3' : 'audio/mp4';
const blob = new Blob([audioBuffer], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audiobook.${audioFormat}`;
document.body.appendChild(a);
a.click();
// Clean up
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
} catch (error) {
console.error('Error generating audiobook:', error);
} finally {
setIsGenerating(false);
setProgress(0);
abortControllerRef.current = null;
const handleGenerateAudiobook = useCallback(async (
onProgress: (progress: number) => void,
signal: AbortSignal,
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
format: 'mp3' | 'm4b'
) => {
if (epub) {
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
} else {
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
}
}, [createFullAudioBook, createPDFAudioBook, epub, audioFormat, setProgress]);
}, [epub, createEPUBAudioBook, createPDFAudioBook, id]);
const handleCancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
const handleRegenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal
) => {
if (epub) {
return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal);
} else {
return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal);
}
};
}, [epub, regenerateEPUBChapter, regeneratePDFChapter]);
return (
<>
<ProgressPopup
isOpen={isGenerating}
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={handleCancel}
isProcessing={epub ? isAudioCombining : isPDFAudioCombining}
cancelText="Cancel and download"
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}
documentType={epub ? 'epub' : 'pdf'}
documentId={id as string}
onGenerateAudiobook={handleGenerateAudiobook}
onRegenerateChapter={handleRegenerateChapter}
/>
<Transition appear show={isOpen} as={Fragment}>
@ -147,7 +121,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
@ -162,44 +136,17 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
{isDev && <div className="space-y-2">
<div className="flex flex-col space-y-2">
<Button
type="button"
disabled={isGenerating}
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
font-medium text-background hover:opacity-95 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={handleStartGeneration}
>
Export to {audioFormat.toUpperCase()} (experimental)
</Button>
<Listbox value={audioFormat} onChange={(format) => setAudioFormat(format as 'mp3' | 'm4b')}>
<div className="relative flex self-end">
<ListboxButton
disabled={isGenerating}
className="flex self-end justify-center items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent">
<span>{audioFormat === 'mp3' ? 'MP3' : 'M4B (Audiobook)'}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</ListboxButton>
<ListboxOptions anchor='bottom end' className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{audioFormats.map((format) => (
<ListboxOption
key={format.id}
value={format.id}
className={({ active, selected }) =>
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
}
>
<span className="text-xs sm:text-sm">{format.name}</span>
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
{isDev && !html && <div className="space-y-2 mb-4">
<Button
type="button"
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:opacity-95 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={() => setIsAudiobookModalOpen(true)}
>
Export Audiobook
</Button>
</div>}
<div className="space-y-4">

View file

@ -9,9 +9,10 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
interface DocumentUploaderProps {
className?: string;
variant?: 'default' | 'compact';
}
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) {
const {
addPDFDocument: addPDF,
addEPUBDocument: addEPUB,
@ -87,41 +88,51 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
disabled: isUploading || isConverting
});
const containerBase = `w-full border-2 border-dashed rounded-lg ${isDragActive ? 'border-accent bg-base' : 'border-muted'} transform transition-transform duration-200 ease-in-out ${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base hover:scale-[1.008]'} ${className}`;
const paddingClass = variant === 'compact' ? 'py-1.5 px-2' : 'py-5 px-3';
return (
<div
{...getRootProps()}
className={`
w-full py-5 px-3 border-2 border-dashed rounded-lg
${isDragActive ? 'border-accent bg-base' : 'border-muted'}
transform trasition-transform duration-200 ease-in-out hover:scale-[1.008]
${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base'}
${className}
`}
className={`${containerBase} ${paddingClass}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center text-center">
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
{isUploading ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">
Uploading file...
</p>
) : isConverting ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">
Converting DOCX to PDF...
</p>
) : (
<>
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
</p>
<p className="text-xs sm:text-sm text-muted">
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
</p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
</>
)}
</div>
{variant === 'compact' ? (
<div className="flex items-center gap-2 text-left">
<UploadIcon className="w-5 h-5 text-muted" />
{isUploading ? (
<p className="text-xs font-medium text-foreground">Uploading</p>
) : isConverting ? (
<p className="text-xs font-medium text-foreground">Converting DOCX</p>
) : (
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-foreground">
{isDragActive ? 'Drop files here' : 'Drop files or click'}
</p>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center text-center">
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
{isUploading ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
) : isConverting ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">Converting DOCX to PDF...</p>
) : (
<>
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
</p>
<p className="text-xs sm:text-sm text-muted">
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
</p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
</>
)}
</div>
)}
</div>
);
}

View file

@ -6,7 +6,6 @@ import { useEPUB } from '@/contexts/EPUBContext';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import TTSPlayer from '@/components/player/TTSPlayer';
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
@ -65,11 +64,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
}
return (
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
<div className="z-10">
<TTSPlayer />
</div>
<div className="flex-1 -mt-16 pt-16">
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
<div className="flex-1">
<ReactReader
loadingView={<DocumentSkeleton />}
key={'epub-reader'}

View file

@ -4,7 +4,6 @@ import { useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useHTML } from '@/contexts/HTMLContext';
import TTSPlayer from '@/components/player/TTSPlayer';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
interface HTMLViewerProps {
@ -20,24 +19,21 @@ export function HTMLViewer({ className = '' }: HTMLViewerProps) {
}
// Check if the file is a txt file
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
return (
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
<div className="z-10">
<TTSPlayer />
</div>
<div className="flex-1 overflow-auto">
<div className={`min-w-full px-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
{isTxtFile ? (
currDocData
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currDocData}
</ReactMarkdown>
)}
return (
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
<div className="flex-1 overflow-auto">
<div className={`min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
{isTxtFile ? (
currDocData
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currDocData}
</ReactMarkdown>
)}
</div>
</div>
</div>
</div>
);
}

29
src/components/Header.tsx Normal file
View file

@ -0,0 +1,29 @@
"use client";
import { ReactNode } from "react";
export function Header({
left,
title,
right,
}: {
left?: ReactNode;
title?: ReactNode;
right?: ReactNode;
}) {
return (
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base" data-app-header>
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
<div className="flex items-center gap-2 min-w-0 flex-1">
{left}
{typeof title === 'string' ? (
<h1 className="text-xs sm:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
) : (
title
)}
</div>
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
'use client';
import { DocumentUploader } from '@/components/DocumentUploader';
import { DocumentList } from '@/components/doclist/DocumentList';
import { useDocuments } from '@/contexts/DocumentContext';
export function HomeContent() {
const { pdfDocs, epubDocs, htmlDocs } = useDocuments();
const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0);
if (totalDocs === 0) {
return (
<div className="max-w-5xl mx-auto">
<DocumentUploader className="py-12" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto">
<DocumentList />
</div>
);
}

View file

@ -7,7 +7,6 @@ import 'react-pdf/dist/Page/TextLayer.css';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import { usePDF } from '@/contexts/PDFContext';
import TTSPlayer from '@/components/player/TTSPlayer';
import { useConfig } from '@/contexts/ConfigContext';
import { usePDFResize } from '@/hooks/pdf/usePDFResize';
@ -48,25 +47,23 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
currDocPage,
} = usePDF();
// Add static styles once during component initialization
const styleElement = document.createElement('style');
styleElement.textContent = `
.react-pdf__Page__textContent span {
cursor: pointer;
transition: background-color 0.2s ease;
}
.react-pdf__Page__textContent span:hover {
background-color: rgba(255, 255, 0, 0.2) !important;
}
`;
document.head.appendChild(styleElement);
// Cleanup styles when component unmounts
// Add static styles once during component mount
useEffect(() => {
const styleElement = document.createElement('style');
styleElement.textContent = `
.react-pdf__Page__textContent span {
cursor: pointer;
transition: background-color 0.2s ease;
}
.react-pdf__Page__textContent span:hover {
background-color: rgba(255, 255, 0, 0.2) !important;
}
`;
document.head.appendChild(styleElement);
return () => {
styleElement.remove();
};
}, [styleElement]);
}, []);
useEffect(() => {
/*
@ -135,7 +132,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
// Modify scale calculation to be more efficient
const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => {
const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type
const containerHeight = window.innerHeight - 100;
const containerHeight = (containerRef.current?.clientHeight ?? window.innerHeight);
const targetWidth = viewType === 'dual'
? (containerWidth - margin) / 2 // divide by 2 for dual pages
: containerWidth - margin;
@ -165,7 +162,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
}, [calculateScale]);
return (
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
<Document
loading={<DocumentSkeleton />}
noData={<DocumentSkeleton />}
@ -240,10 +237,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
)}
</div>
</Document>
<TTSPlayer
currentPage={currDocPage}
numPages={currDocPages}
/>
</div>
);
}

View file

@ -0,0 +1,96 @@
import { LoadingSpinner } from './Spinner';
interface ProgressCardProps {
progress: number;
estimatedTimeRemaining?: string;
onCancel: (e?: React.MouseEvent) => void;
isProcessing?: boolean;
operationType?: 'sync' | 'load' | 'audiobook';
cancelText?: string;
currentChapter?: string;
completedChapters?: number;
statusMessage?: string;
}
export function ProgressCard({
progress,
estimatedTimeRemaining,
onCancel,
isProcessing = false,
operationType,
cancelText = 'Cancel',
currentChapter,
completedChapters,
statusMessage
}: ProgressCardProps) {
const getOperationLabel = () => {
if (operationType === 'sync') return 'Saving to Server';
if (operationType === 'load') return 'Loading from Server';
if (operationType === 'audiobook') return 'Generating Audiobook';
return null;
};
const operationLabel = getOperationLabel();
return (
<div className="bg-offbase rounded-lg p-3 space-y-2">
{/* Header with operation type and cancel button */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0 space-y-1">
{operationLabel && (
<div className="text-accent font-semibold text-xs uppercase tracking-wide">
{operationLabel}
</div>
)}
{statusMessage && (
<div className="text-sm font-medium text-foreground truncate" title={statusMessage}>
{statusMessage}
</div>
)}
{currentChapter && (
<div className="text-sm font-medium text-foreground truncate" title={currentChapter}>
{currentChapter}
</div>
)}
</div>
<button
type="button"
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors"
onClick={(e) => onCancel(e)}
>
{isProcessing && (
<span className="w-3 h-3">
<LoadingSpinner />
</span>
)}
<span>{cancelText}</span>
</button>
</div>
{/* Progress bar */}
<div className="w-full bg-background rounded-full overflow-hidden h-1.5">
<div
className="h-full bg-accent transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{/* Stats row */}
<div className="flex items-center gap-2 text-xs text-muted">
{completedChapters !== undefined && (
<>
<span className="font-medium">{completedChapters} chapters</span>
<span></span>
</>
)}
<span className="font-medium">{Math.round(progress)}%</span>
{estimatedTimeRemaining && (
<>
<span></span>
<span>{estimatedTimeRemaining}</span>
</>
)}
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { Fragment } from 'react';
import { Transition } from '@headlessui/react';
import { LoadingSpinner } from './Spinner';
import { ProgressCard } from './ProgressCard';
interface ProgressPopupProps {
isOpen: boolean;
@ -9,11 +9,27 @@ interface ProgressPopupProps {
onCancel: () => void;
isProcessing: boolean;
statusMessage?: string;
operationType?: 'sync' | 'load';
operationType?: 'sync' | 'load' | 'audiobook';
cancelText?: string;
onClick?: () => void;
currentChapter?: string;
totalChapters?: number;
completedChapters?: number;
}
export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCancel, isProcessing, statusMessage, operationType, cancelText = 'Cancel' }: ProgressPopupProps) {
export function ProgressPopup({
isOpen,
progress,
estimatedTimeRemaining,
onCancel,
isProcessing,
statusMessage,
operationType,
cancelText = 'Cancel',
onClick,
currentChapter,
completedChapters
}: ProgressPopupProps) {
return (
<Transition
show={isOpen}
@ -25,46 +41,28 @@ export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCanc
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-4"
>
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none">
<div className="w-full max-w-sm mx-auto">
<div className="w-full transform rounded-lg bg-offbase p-4 shadow-xl pointer-events-auto">
<div className="space-y-2 truncate">
<div className="w-full bg-background rounded-lg overflow-hidden">
<div
className="h-2 bg-accent transition-all duration-300 ease-in-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex justify-between items-center text-sm text-muted text-xs sm:text-sm">
<div className="flex flex-col gap-1">
{operationType && (
<span className="text-accent font-semibold text-xs uppercase tracking-wide">
{operationType === 'sync' ? 'Saving to Server' : 'Loading from Server'}
</span>
)}
{statusMessage && <span className="text-foreground font-medium">{statusMessage}</span>}
<div className="flex flex-wrap items-center gap-1">
<span>{Math.round(progress)}% complete</span>
{estimatedTimeRemaining && <div>
<span>&bull;</span>
<span>{` ~${estimatedTimeRemaining}`}</span>
</div>}
</div>
</div>
<button
type="button"
className="inline-flex items-center justify-center gap-1 rounded-lg px-2.5 py-1 font-medium text-foreground hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
onClick={onCancel}
>
{isProcessing && (
<span className="relative inline-block w-4 h-4">
<LoadingSpinner />
</span>
)}
<span>{cancelText}</span>
</button>
</div>
</div>
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none px-4">
<div className="w-full max-w-md mx-auto">
<div
className={`pointer-events-auto shadow-xl ${
onClick ? 'cursor-pointer' : ''
}`}
onClick={onClick}
>
<ProgressCard
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining}
onCancel={(e) => {
e?.stopPropagation();
onCancel();
}}
isProcessing={isProcessing}
operationType={operationType}
cancelText={cancelText}
currentChapter={currentChapter}
completedChapters={completedChapters}
statusMessage={statusMessage}
/>
</div>
</div>
</div>

View file

@ -267,11 +267,11 @@ export function SettingsModal() {
<>
<Button
onClick={() => setIsOpen(true)}
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent absolute top-1 left-1 sm:top-3 sm:left-3"
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent absolute top-2 right-2 sm:top-4 sm:right-4"
aria-label="Settings"
tabIndex={0}
>
<SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 hover:animate-spin-slow" />
<SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 transform transition-transform duration-200 ease-in-out hover:rotate-45" />
</Button>
<Transition appear show={isOpen} as={Fragment}>
@ -285,7 +285,7 @@ export function SettingsModal() {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
@ -316,8 +316,8 @@ export function SettingsModal() {
`w-full rounded-lg py-1 text-sm font-medium
ring-accent/60 ring-offset-2 ring-offset-base
${selected
? 'bg-accent text-white shadow'
: 'text-foreground hover:bg-accent/[0.12] hover:text-accent'
? 'bg-accent text-background shadow'
: 'text-foreground hover:text-accent'
}`
}
>
@ -329,8 +329,8 @@ export function SettingsModal() {
))}
</TabList>
<TabPanels className="mt-2">
<TabPanel className="space-y-4">
<div className="space-y-2">
<TabPanel className="space-y-2.5">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
<Listbox
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
@ -395,7 +395,7 @@ export function SettingsModal() {
</Listbox>
</div>
<div className="space-y-2">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">
API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
@ -411,7 +411,7 @@ export function SettingsModal() {
</div>
</div>
<div className="space-y-2">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">TTS Model</label>
<div className="flex flex-col gap-2">
<Listbox
@ -485,7 +485,7 @@ export function SettingsModal() {
</div>
{modelValue === 'gpt-4o-mini-tts' && (
<div className="space-y-2">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
<textarea
value={localTTSInstructions}
@ -497,7 +497,7 @@ export function SettingsModal() {
)}
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
<div className="space-y-2">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">
API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
@ -514,7 +514,7 @@ export function SettingsModal() {
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<div className="pt-4 flex justify-end gap-2">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
@ -534,8 +534,8 @@ export function SettingsModal() {
</Button>
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-white hover:bg-accent/90 focus:outline-none
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-accent/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
disabled={!canSubmit}
@ -557,7 +557,7 @@ export function SettingsModal() {
</TabPanel>
<TabPanel className="space-y-4 pb-3">
<div className="space-y-2">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">Theme</label>
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
@ -603,7 +603,7 @@ export function SettingsModal() {
</TabPanel>
<TabPanel className="space-y-4">
{isDev && <div className="space-y-2">
{isDev && <div className="space-y-1">
<label className="block text-sm font-medium text-foreground">Document Sync</label>
<div className="flex gap-2">
<Button
@ -631,23 +631,23 @@ export function SettingsModal() {
</div>
</div>}
<div className="space-y-2 pb-3">
<div className="space-y-1 pb-3">
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
<div className="flex gap-2">
<Button
onClick={() => setShowClearLocalConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
font-medium text-background hover:bg-accent/90 focus:outline-none
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete local docs
</Button>
{isDev && <Button
onClick={() => setShowClearServerConfirm(true)}
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
font-medium text-white hover:bg-red-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
font-medium text-background hover:bg-red-500/90 focus:outline-none
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete server docs

View file

@ -0,0 +1,39 @@
"use client";
export function ZoomControl({
value,
onIncrease,
onDecrease,
min = 50,
max = 300,
}: {
value: number;
onIncrease: () => void;
onDecrease: () => void;
min?: number;
max?: number;
}) {
return (
<div className="flex items-center gap-1 select-none" aria-label="Zoom controls">
<button
type="button"
onClick={onDecrease}
disabled={value <= min}
className="px-1 text-sm leading-none text-foreground hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.09]"
aria-label="Zoom out"
>
</button>
<span className="text-xs tabular-nums w-12 text-center text-muted">{value}%</span>
<button
type="button"
onClick={onIncrease}
disabled={value >= max}
className="px-1 text-sm leading-none text-foreground hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.09]"
aria-label="Zoom in"
>
</button>
</div>
);
}

View file

@ -28,7 +28,7 @@ export function CreateFolderDialog({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">

View file

@ -16,7 +16,7 @@ interface DocumentFolderProps {
onDrop: (e: DragEvent, folderId: string) => void;
}
const ChevronIcon = ({ className = "w-5 h-5" }) => (
const ChevronIcon = ({ className = "w-4 h-4" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
@ -62,36 +62,33 @@ export function DocumentFolder({
if (!draggedDoc || draggedDoc.folderId) return;
onDrop(e, folder.id);
}}
className={`overflow-hidden rounded-lg transition-all bg-offbase shadow hover:shadow-md ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
className={`overflow-hidden rounded-md border border-offbase ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
>
<div className='flex flex-row justify-between p-2'>
<div className='flex flex-row justify-between p-0'>
<div className="w-full">
<div className={`flex items-center justify-between ${isCollapsed ? 'mb-0' : 'mb-2'} transition-all duration-200`}>
<div className={`flex items-center justify-between px-2 py-1 bg-offbase rounded-t-md border-b border-offbase transition-all duration-200`}>
<div className="flex items-center">
<h3 className="text-lg px-1 font-semibold">{folder.name}</h3>
<h3 className="text-sm px-1 font-semibold leading-tight">{folder.name}</h3>
<Button
onClick={() => onToggleCollapse(folder.id)}
className="transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:font-semibold hover:text-accent"
className="ml-0.5 transform transition-transform duration-200 ease-in-out hover:scale-[1.08] hover:text-accent"
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
>
<ChevronIcon
className={`w-5 h-5 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`}
/>
<ChevronIcon className={`transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
</Button>
</div>
<Button
onClick={onDelete}
className="p-1 text-muted hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
className="p-1 text-muted hover:text-accent hover:bg-base rounded-md transition-colors"
aria-label="Delete folder"
>
<svg className="w-4 h-4"
<svg className="w-3.5 h-3.5"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
<div className="relative">
<div className="relative bg-base px-1 py-1 rounded-b-md">
<Transition
show={!isCollapsed}
enter="transition-all duration-300 ease-out"
@ -101,7 +98,7 @@ export function DocumentFolder({
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
leaveTo="transform scale-y-0 opacity-0 max-h-0"
>
<div className="space-y-2 origin-top">
<div className="space-y-1 origin-top">
{sortedDocuments.map(doc => (
<DocumentListItem
key={`${doc.type}-${doc.id}`}
@ -125,7 +122,7 @@ export function DocumentFolder({
leaveFrom="max-h-[50px] opacity-100"
leaveTo="max-h-0 opacity-0"
>
<p className="text-xs px-1 text-left text-muted">
<p className="text-[10px] px-1 text-left text-muted leading-tight">
{(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
{`${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
</p>

View file

@ -12,6 +12,7 @@ import { DocumentFolder } from '@/components/doclist/DocumentFolder';
import { SortControls } from '@/components/doclist/SortControls';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { Button } from '@headlessui/react';
import { DocumentUploader } from '@/components/DocumentUploader';
type DocumentToDelete = {
id: string;
@ -297,11 +298,18 @@ export function DocumentList() {
doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
);
// Build compact summary (counts per type + total size)
const summaryParts: string[] = [];
if (pdfDocs.length) summaryParts.push(`${pdfDocs.length} PDF${pdfDocs.length === 1 ? '' : 's'}`);
if (epubDocs.length) summaryParts.push(`${epubDocs.length} EPUB${epubDocs.length === 1 ? '' : 's'}`);
if (htmlDocs.length) summaryParts.push(`${htmlDocs.length} HTML${htmlDocs.length === 1 ? '' : 's'}`);
const totalSizeMB = (allDocuments.reduce((acc, d) => acc + d.size, 0) / 1024 / 1024).toFixed(2);
return (
<DndProvider backend={HTML5Backend}>
<div className="w-full mx-auto">
<div className="flex items-center justify-between mb-2 sm:mb-4">
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Your Documents</h2>
<div className="flex items-center justify-between">
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Local Documents</h2>
<SortControls
sortBy={sortBy}
sortDirection={sortDirection}
@ -309,14 +317,21 @@ export function DocumentList() {
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
/>
</div>
<p className="text-xs text-muted mb-2" data-doc-summary>
{summaryParts.join(' • ')}{summaryParts.length ? ' • ' : ''}{totalSizeMB} MB total
</p>
<div className="mb-3">
<DocumentUploader variant="compact" />
</div>
<div className="space-y-2">
{showHint && allDocuments.length > 1 && (
<div className="flex items-center justify-between bg-background rounded-lg px-3 py-2 text-sm shadow hover:shadow-md transition-shadow">
<p className="text-sm">Drag files on top of each other to make folders</p>
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm">
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
<Button
onClick={() => setShowHint(false)}
className="p-1 hover:bg-accent rounded-lg transition-colors"
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
aria-label="Dismiss hint"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -3,7 +3,6 @@ import { DragEvent, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@headlessui/react';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
import { DocumentListDocument } from '@/types/documents';
interface DocumentListItemProps {
@ -50,41 +49,44 @@ export function DocumentListItem({
onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)}
onDragLeave={() => allowDropTarget && onDragLeave?.()}
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
aria-busy={loading}
className={`
w-full
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent bg-primary/10' : ''}
bg-background rounded-lg p-2 shadow hover:shadow-md transition-shadow
relative
w-full group
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
border border-offbase rounded-md p-1
transition-colors duration-150 relative
`}
>
{loading && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/80 rounded-lg">
<LoadingSpinner />
</div>
)}
<div className="flex items-center rounded-lg">
<div className="flex items-center">
<Link
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
draggable={false}
className="document-link flex items-center align-center space-x-4 w-full truncate hover:bg-base rounded-lg p-0.5 sm:p-1 transition-colors"
className="document-link flex items-center align-center gap-2 w-full truncate rounded-md py-0.5 px-0.5"
onClick={handleDocumentClick}
>
<div className="flex-shrink-0">
{doc.type === 'pdf' ? <PDFIcon /> : doc.type === 'epub' ? <EPUBIcon /> : <FileIcon />}
{doc.type === 'pdf' ? (
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
) : doc.type === 'epub' ? (
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
) : (
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
)}
</div>
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
<p className="text-xs sm:text-sm text-muted truncate">
<div className="flex flex-col min-w-0 transform transition-transform duration-150 ease-in-out hover:scale-[1.009] w-full truncate">
<p className="text-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">{doc.name}</p>
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</Link>
<Button
onClick={() => onDelete(doc)}
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors"
aria-label="Delete document"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>

View file

@ -29,13 +29,13 @@ export function SortControls({
<div className="flex items-center gap-1">
<Button
onClick={onSortDirectionChange}
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap"
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
{directionLabel}
</Button>
<div className="relative">
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</ListboxButton>

View file

@ -1,10 +1,12 @@
export const PlayIcon = () => (
export const PlayIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
width="32"
height="32"
width={props.width || 24}
height={props.height || 24}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
{...props}
>
<circle
cx="16"
@ -22,13 +24,15 @@ export const PlayIcon = () => (
</svg>
);
export const PauseIcon = () => (
export const PauseIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
width="32"
height="32"
width={props.width || 24}
height={props.height || 24}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
{...props}
>
<circle
cx="16"
@ -59,13 +63,15 @@ export const PauseIcon = () => (
</svg>
);
export const SkipForwardIcon = () => (
export const SkipForwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
width="24"
height="24"
width={props.width || 24}
height={props.height || 24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
{...props}
>
<path
d="M6 4l10 8-10 8V4z"
@ -84,13 +90,15 @@ export const SkipForwardIcon = () => (
</svg>
);
export const SkipBackwardIcon = () => (
export const SkipBackwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
width="24"
height="24"
width={props.width || 24}
height={props.height || 24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
{...props}
>
<path
d="M18 4L8 12l10 8V4z"
@ -262,3 +270,147 @@ export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function DownloadIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
);
}
export function CheckCircleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
</svg>
);
}
export function XCircleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" clipRule="evenodd" />
</svg>
);
}
export function ClockIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zM12.75 6a.75.75 0 00-1.5 0v6c0 .414.336.75.75.75h4.5a.75.75 0 000-1.5h-3.75V6z" clipRule="evenodd" />
</svg>
);
}
export function RefreshIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
);
}
export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</svg>
);
}
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"
></path>
</svg>
);
}
export function AudioWaveIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path d="M6 9.85986V14.1499" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
<path d="M9 8.42993V15.5699" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
<path d="M12 7V17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
<path d="M15 8.42993V15.5699" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
<path d="M18 9.85986V14.1499" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
</svg>
);
}

View file

@ -64,7 +64,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
<Button
onClick={() => skipToLocation(currentPage - 1, true)}
disabled={currentPage <= 1}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Previous page"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
@ -82,7 +82,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
return (
<>
<PopoverButton
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase/80"
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={handlePopoverOpen}
>
<p className="text-xs whitespace-nowrap">
@ -105,7 +105,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
placeholder={currentPage.toString()}
aria-label="Page number"
/>
<div className="text-xs text-foreground/70 text-center">of {numPages || 1}</div>
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
</div>
</PopoverPanel>
</>
@ -117,7 +117,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
<Button
onClick={() => skipToLocation(currentPage + 1, true)}
disabled={currentPage >= (numPages || 1)}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Next page"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">

View file

@ -1,7 +1,7 @@
'use client';
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { ChevronUpDownIcon } from '@/components/icons/Icons';
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
import { useConfig } from '@/contexts/ConfigContext';
import { useCallback, useEffect, useState } from 'react';
@ -54,7 +54,8 @@ export const SpeedControl = ({
return (
<Popover className="relative">
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</PopoverButton>

View file

@ -31,8 +31,8 @@ export default function TTSPlayer({ currentPage, numPages }: {
} = useTTS();
return (
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100 border border-offbase">
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
{/* Speed control */}
<SpeedControl
setSpeedAndRestart={setSpeedAndRestart}
@ -51,29 +51,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
{/* Playback Controls */}
<Button
onClick={skipBackward}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Skip backward"
disabled={isProcessing}
>
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
</Button>
<Button
onClick={togglePlay}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label={isPlaying ? 'Pause' : 'Play'}
disabled={isProcessing}
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
</Button>
<Button
onClick={skipForward}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Skip forward"
disabled={isProcessing}
>
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
</Button>
{/* Voice control */}

View file

@ -6,7 +6,7 @@ import {
ListboxOption,
ListboxOptions,
} from '@headlessui/react';
import { ChevronUpDownIcon } from '@/components/icons/Icons';
import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons';
import { useConfig } from '@/contexts/ConfigContext';
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
@ -23,11 +23,12 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
return (
<div className="relative">
<Listbox value={currentVoice} onChange={setVoiceAndRestart}>
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
<span>{currentVoice}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</ListboxButton>
<ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{availableVoices.map((voiceId) => (
<ListboxOption
key={voiceId}

View file

@ -19,7 +19,6 @@ import { setLastDocumentLocation } from '@/utils/indexedDB';
import { SpineItem } from 'epubjs/types/section';
import { useParams } from 'next/navigation';
import { useConfig } from './ConfigContext';
import { combineAudioChunks } from '@/utils/audio';
import { withRetry } from '@/utils/audio';
interface EPUBContextType {
@ -31,7 +30,8 @@ interface EPUBContextType {
setCurrentDocument: (id: string) => Promise<void>;
clearCurrDoc: () => void;
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', onProgress: (progress: number) => void, signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
bookRef: RefObject<Book | null>;
renditionRef: RefObject<Rendition | undefined>;
tocRef: RefObject<NavItem[]>;
@ -59,12 +59,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
voiceSpeed,
voice,
ttsProvider,
ttsModel,
ttsInstructions,
} = useConfig();
// Current document state
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
const [isAudioCombining, setIsAudioCombining] = useState(false);
const [isAudioCombining] = useState(false);
// Add new refs
const bookRef = useRef<Book | null>(null);
@ -197,21 +199,43 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const createFullAudioBook = useCallback(async (
onProgress: (progress: number) => void,
signal?: AbortSignal,
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
providedBookId?: string,
format: 'mp3' | 'm4b' = 'mp3'
): Promise<ArrayBuffer> => {
): Promise<string> => {
try {
const sections = await extractBookText();
if (!sections.length) throw new Error('No text content found in book');
// Calculate total length for accurate progress tracking
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
let processedLength = 0;
let currentTime = 0;
let bookId: string = providedBookId || '';
// Get TOC for chapter titles
const chapters = tocRef.current || [];
console.log('Chapters:', chapters);
// If we have a bookId, check for existing chapters to determine which indices already exist
const existingIndices = new Set<number>();
if (bookId) {
try {
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
if (existingResponse.ok) {
const existingData = await existingResponse.json();
if (existingData.chapters && existingData.chapters.length > 0) {
for (const ch of existingData.chapters) {
existingIndices.add(ch.index);
}
// Log smallest missing index for visibility
let nextMissing = 0;
while (existingIndices.has(nextMissing)) nextMissing++;
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
}
}
} catch (error) {
console.error('Error checking existing chapters:', error);
}
}
// Create a map of section hrefs to their chapter titles
const sectionTitleMap = new Map<string, string>();
@ -233,35 +257,52 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}
}
}
console.log('Section to chapter title mapping:', sectionTitleMap);
// Process each section
for (let i = 0; i < sections.length; i++) {
// Check for abort at the start of iteration
if (signal?.aborted) {
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
return partialBuffer;
console.log('Generation cancelled by user');
if (bookId) {
return bookId; // Return bookId with partial progress
}
throw new Error('Audiobook generation cancelled');
}
const section = sections[i];
const trimmedText = section.text.trim();
if (!trimmedText) continue;
// Skip chapters that already exist on disk (supports non-contiguous indices)
if (existingIndices.has(i)) {
processedLength += trimmedText.length;
onProgress((processedLength / totalLength) * 100);
continue;
}
try {
const audioBuffer = await withRetry(
async () => {
// Check for abort before starting TTS request
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': ttsProvider,
},
body: JSON.stringify({
text: trimmedText,
voice: voice,
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
speed: voiceSpeed,
format: format === 'm4b' ? 'aac' : 'mp3',
format: 'mp3',
model: ttsModel,
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
}),
signal
});
@ -289,47 +330,232 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
// If no chapter title found, use index-based naming
if (!chapterTitle) {
chapterTitle = `Unknown Section - ${i + 1}`;
chapterTitle = `Chapter ${i + 1}`;
}
console.log('Processed audiobook chapter title:', chapterTitle);
audioChunks.push({
buffer: audioBuffer,
title: chapterTitle,
startTime: currentTime
// Check for abort before sending to server
if (signal?.aborted) {
console.log('Generation cancelled before saving chapter');
if (bookId) {
return bookId;
}
throw new Error('Audiobook generation cancelled');
}
// Send to server for conversion and storage
const convertResponse = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex: i
}),
signal
});
// Add silence between sections
const silenceBuffer = new ArrayBuffer(48000);
audioChunks.push({
buffer: silenceBuffer,
startTime: currentTime + (audioBuffer.byteLength / 48000)
});
if (convertResponse.status === 499) {
throw new Error('cancelled');
}
if (!convertResponse.ok) {
throw new Error('Failed to convert audio chapter');
}
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
if (!bookId) {
bookId = returnedBookId;
}
// Notify about completed chapter
if (onChapterComplete) {
onChapterComplete({
index: chapterIndex,
title: chapterTitle,
duration,
status: 'completed',
bookId,
format
});
}
currentTime += (audioBuffer.byteLength + 48000) / 48000;
processedLength += trimmedText.length;
onProgress((processedLength / totalLength) * 100);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.log('TTS request aborted');
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
return partialBuffer;
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
console.log('TTS request aborted, returning partial progress');
if (bookId) {
return bookId; // Return with partial progress
}
throw new Error('Audiobook generation cancelled');
}
console.error('Error processing section:', error);
// Notify about error
if (onChapterComplete) {
onChapterComplete({
index: i,
title: sectionTitleMap.get(section.href) || `Chapter ${i + 1}`,
status: 'error',
bookId,
format
});
}
}
}
if (audioChunks.length === 0) {
if (!bookId) {
throw new Error('No audio was generated from the book content');
}
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
return bookId;
} catch (error) {
console.error('Error creating audiobook:', error);
throw error;
}
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
/**
* Regenerates a specific chapter of the audiobook
*/
const regenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
try {
const sections = await extractBookText();
if (chapterIndex >= sections.length) {
throw new Error('Invalid chapter index');
}
const section = sections[chapterIndex];
const trimmedText = section.text.trim();
if (!trimmedText) {
throw new Error('No text content found in chapter');
}
// Get TOC for chapter title
const chapters = tocRef.current || [];
const sectionTitleMap = new Map<string, string>();
for (const chapter of chapters) {
if (!chapter.href) continue;
const chapterBaseHref = chapter.href.split('#')[0];
const chapterTitle = chapter.label.trim();
for (const sect of sections) {
const sectionHref = sect.href;
const sectionBaseHref = sectionHref.split('#')[0];
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
sectionTitleMap.set(sectionHref, chapterTitle);
}
}
}
const chapterTitle = sectionTitleMap.get(section.href) || `Chapter ${chapterIndex + 1}`;
// Generate audio with retry logic
const audioBuffer = await withRetry(
async () => {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': ttsProvider,
},
body: JSON.stringify({
text: trimmedText,
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
speed: voiceSpeed,
format: 'mp3',
model: ttsModel,
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
}),
signal
});
if (!ttsResponse.ok) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
}
const buffer = await ttsResponse.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
return buffer;
},
{
maxRetries: 2,
initialDelay: 5000,
maxDelay: 10000,
backoffFactor: 2
}
);
if (signal?.aborted) {
throw new Error('Chapter regeneration cancelled');
}
// Send to server for conversion and storage
const convertResponse = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex
}),
signal
});
if (convertResponse.status === 499) {
throw new Error('cancelled');
}
if (!convertResponse.ok) {
throw new Error('Failed to convert audio chapter');
}
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
return {
index: returnedIndex,
title: chapterTitle,
duration,
status: 'completed',
bookId,
format
};
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
throw new Error('Chapter regeneration cancelled');
}
console.error('Error regenerating chapter:', error);
throw error;
}
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
const setRendition = useCallback((rendition: Rendition) => {
bookRef.current = rendition.book;
@ -387,6 +613,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
clearCurrDoc,
extractPageText,
createFullAudioBook,
regenerateChapter,
bookRef,
renditionRef,
tocRef,
@ -405,6 +632,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
clearCurrDoc,
extractPageText,
createFullAudioBook,
regenerateChapter,
handleLocationChanged,
setRendition,
isAudioCombining,

View file

@ -33,7 +33,7 @@ const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
*/
export function HTMLProvider({ children }: { children: ReactNode }) {
const { setText: setTTSText, stop } = useTTS();
const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider } = useConfig();
const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider, ttsModel, ttsInstructions } = useConfig();
// Current document state
const [currDocData, setCurrDocData] = useState<string>();
@ -95,15 +95,18 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': ttsProvider,
},
body: JSON.stringify({
text: currDocText,
voice: voice,
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
speed: voiceSpeed,
format: format === 'm4b' ? 'aac' : 'mp3'
format: 'mp3',
model: ttsModel,
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
}),
signal
});
@ -147,7 +150,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
console.error('Error creating audiobook:', error);
throw error;
}
}, [currDocText, currDocName, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
}, [currDocText, currDocName, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
const contextValue = useMemo(() => ({
currDocData,

View file

@ -35,7 +35,7 @@ import {
} from '@/utils/pdf';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { combineAudioChunks, withRetry } from '@/utils/audio';
import { withRetry } from '@/utils/audio';
/**
* Interface defining all available methods and properties in the PDF context
@ -62,7 +62,8 @@ interface PDFContextType {
stopAndPlayFromIndex: (index: number) => void,
isProcessing: boolean
) => void;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', onProgress: (progress: number) => void, signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
isAudioCombining: boolean;
}
@ -97,6 +98,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
voiceSpeed,
voice,
ttsProvider,
ttsModel,
ttsInstructions,
} = useConfig();
// Current document state
@ -104,7 +107,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
const [isAudioCombining, setIsAudioCombining] = useState(false);
const [isAudioCombining] = useState(false);
/**
* Handles successful PDF document load
@ -189,14 +192,16 @@ export function PDFProvider({ children }: { children: ReactNode }) {
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
* @param {Function} onProgress - Callback for progress updates
* @param {AbortSignal} signal - Optional signal for cancellation
* @param {string} format - Optional format for the audiobook ('mp3' or 'm4b')
* @returns {Promise<ArrayBuffer>} The complete audiobook as an ArrayBuffer
* @param {Function} onChapterComplete - Optional callback for when a chapter completes
* @returns {Promise<string>} The bookId for the generated audiobook
*/
const createFullAudioBook = useCallback(async (
onProgress: (progress: number) => void,
signal?: AbortSignal,
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
providedBookId?: string,
format: 'mp3' | 'm4b' = 'mp3'
): Promise<ArrayBuffer> => {
): Promise<string> => {
try {
if (!pdfDocument) {
throw new Error('No PDF document loaded');
@ -224,33 +229,72 @@ export function PDFProvider({ children }: { children: ReactNode }) {
throw new Error('No text content found in PDF');
}
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
let processedLength = 0;
let currentTime = 0;
let bookId: string = providedBookId || '';
// If we have a bookId, check for existing chapters to determine which indices already exist
const existingIndices = new Set<number>();
if (bookId) {
try {
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
if (existingResponse.ok) {
const existingData = await existingResponse.json();
if (existingData.chapters && existingData.chapters.length > 0) {
for (const ch of existingData.chapters) {
existingIndices.add(ch.index);
}
let nextMissing = 0;
while (existingIndices.has(nextMissing)) nextMissing++;
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
}
}
} catch (error) {
console.error('Error checking existing chapters:', error);
}
}
// Second pass: process text into audio
for (let i = 0; i < textPerPage.length; i++) {
// Check for abort at the start of iteration
if (signal?.aborted) {
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
return partialBuffer;
console.log('Generation cancelled by user');
if (bookId) {
return bookId; // Return bookId with partial progress
}
throw new Error('Audiobook generation cancelled');
}
const text = textPerPage[i];
// Skip pages that already exist on disk (supports non-contiguous indices)
if (existingIndices.has(i)) {
processedLength += text.length;
onProgress((processedLength / totalLength) * 100);
continue;
}
try {
const audioBuffer = await withRetry(
async () => {
// Check for abort before starting TTS request
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': ttsProvider,
},
body: JSON.stringify({
text,
voice: voice,
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
speed: voiceSpeed,
format: format === 'm4b' ? 'aac' : 'mp3'
format: 'mp3',
model: ttsModel,
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
}),
signal
});
@ -273,43 +317,239 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}
);
audioChunks.push({
buffer: audioBuffer,
title: `Page ${i + 1}`,
startTime: currentTime
const chapterTitle = `Page ${i + 1}`;
// Check for abort before sending to server
if (signal?.aborted) {
console.log('Generation cancelled before saving page');
if (bookId) {
return bookId;
}
throw new Error('Audiobook generation cancelled');
}
// Send to server for conversion and storage
const convertResponse = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex: i
}),
signal
});
// Add a small pause between pages (1s of silence)
const silenceBuffer = new ArrayBuffer(48000);
audioChunks.push({
buffer: silenceBuffer,
startTime: currentTime + (audioBuffer.byteLength / 48000)
});
if (convertResponse.status === 499) {
throw new Error('cancelled');
}
if (!convertResponse.ok) {
throw new Error('Failed to convert audio chapter');
}
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
if (!bookId) {
bookId = returnedBookId;
}
// Notify about completed chapter
if (onChapterComplete) {
onChapterComplete({
index: chapterIndex,
title: chapterTitle,
duration,
status: 'completed',
bookId,
format
});
}
currentTime += (audioBuffer.byteLength + 48000) / 48000;
processedLength += text.length;
onProgress((processedLength / totalLength) * 100);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.log('TTS request aborted');
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
return partialBuffer;
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
console.log('TTS request aborted, returning partial progress');
if (bookId) {
return bookId; // Return with partial progress
}
throw new Error('Audiobook generation cancelled');
}
console.error('Error processing page:', error);
// Notify about error
if (onChapterComplete) {
onChapterComplete({
index: i,
title: `Page ${i + 1}`,
status: 'error',
bookId,
format
});
}
}
}
if (audioChunks.length === 0) {
if (!bookId) {
throw new Error('No audio was generated from the PDF content');
}
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
return bookId;
} catch (error) {
console.error('Error creating audiobook:', error);
throw error;
}
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
/**
* Regenerates a specific chapter (page) of the PDF audiobook
*/
const regenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
try {
if (!pdfDocument) {
throw new Error('No PDF document loaded');
}
// IMPORTANT: Chapter indices are based on non-empty pages used during generation.
// Build a mapping of "chapterIndex" -> actual PDF page number (1-based).
const nonEmptyPages: number[] = [];
for (let page = 1; page <= pdfDocument.numPages; page++) {
const pageText = await extractTextFromPDF(pdfDocument, page, {
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin
});
if (pageText.trim()) {
nonEmptyPages.push(page);
}
}
if (chapterIndex < 0 || chapterIndex >= nonEmptyPages.length) {
throw new Error('Invalid chapter index');
}
const pageNum = nonEmptyPages[chapterIndex];
// Extract text from the mapped page
const text = await extractTextFromPDF(pdfDocument, pageNum, {
header: headerMargin,
footer: footerMargin,
left: leftMargin,
right: rightMargin
});
const trimmedText = text.trim();
if (!trimmedText) {
throw new Error('No text content found on page');
}
// Use logical chapter numbering (index + 1) to match original generation titles
const chapterTitle = `Page ${chapterIndex + 1}`;
// Generate audio with retry logic
const audioBuffer = await withRetry(
async () => {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-openai-key': apiKey,
'x-openai-base-url': baseUrl,
'x-tts-provider': ttsProvider,
},
body: JSON.stringify({
text: trimmedText,
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
speed: voiceSpeed,
format: 'mp3',
model: ttsModel,
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
}),
signal
});
if (!ttsResponse.ok) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
}
const buffer = await ttsResponse.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
return buffer;
},
{
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffFactor: 2
}
);
if (signal?.aborted) {
throw new Error('Page regeneration cancelled');
}
// Send to server for conversion and storage
const convertResponse = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex
}),
signal
});
if (convertResponse.status === 499) {
throw new Error('cancelled');
}
if (!convertResponse.ok) {
throw new Error('Failed to convert audio chapter');
}
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
return {
index: returnedIndex,
title: chapterTitle,
duration,
status: 'completed',
bookId,
format
};
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
throw new Error('Page regeneration cancelled');
}
console.error('Error regenerating page:', error);
throw error;
}
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
/**
* Effect hook to initialize TTS as non-EPUB mode
@ -334,6 +574,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
handleTextClick,
pdfDocument,
createFullAudioBook,
regenerateChapter,
isAudioCombining,
}),
[
@ -347,6 +588,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
clearCurrDoc,
pdfDocument,
createFullAudioBook,
regenerateChapter,
isAudioCombining,
]
);

View file

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

View file

@ -88,12 +88,12 @@ export function useTimeEstimation(): TimeEstimation {
}
}
lastProgressUpdateRef.current = currentTime;
if (shouldSkipUpdate) {
return;
}
lastProgressUpdateRef.current = currentTime;
const history = progressHistoryRef.current;
if (history.length < 2) {

View file

@ -90,7 +90,8 @@ export const combineAudioChunks = async (
body: JSON.stringify({
chapterTitle: chunk.title,
buffer: Array.from(new Uint8Array(chunk.buffer)),
bookId // Will be undefined for first chunk, then set for subsequent ones
bookId, // Will be undefined for first chunk, then set for subsequent ones
format
}),
});
@ -106,8 +107,8 @@ export const combineAudioChunks = async (
throw new Error('No book ID received from server');
}
// Get the final combined M4B file
const m4bResponse = await fetch(`/api/audio/convert?bookId=${bookId}`);
// Get the final combined M4B/MP3 file
const m4bResponse = await fetch(`/api/audio/convert?bookId=${bookId}&format=${format}`);
if (!m4bResponse.ok) {
throw new Error('Failed to get combined M4B');
}

View file

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