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:
parent
e7ce1a34ce
commit
42665884d7
39 changed files with 2767 additions and 607 deletions
134
src/app/api/audio/convert/chapter/route.ts
Normal file
134
src/app/api/audio/convert/chapter/route.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { readFile, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
|
||||
|
||||
if (!bookId || !chapterIndexStr) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing bookId or chapterIndex parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const chapterIndex = parseInt(chapterIndexStr);
|
||||
if (isNaN(chapterIndex)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid chapterIndex parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const docstoreDir = join(process.cwd(), 'docstore');
|
||||
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||
|
||||
// Read metadata to get format
|
||||
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
||||
if (!existsSync(metadataPath)) {
|
||||
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'));
|
||||
const format = metadata.format || 'm4b';
|
||||
const chapterPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
|
||||
|
||||
if (!existsSync(chapterPath)) {
|
||||
return NextResponse.json({ error: 'Chapter file not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Stream the chapter file
|
||||
const stream = createReadStream(chapterPath);
|
||||
|
||||
const readableWebStream = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||
const sanitizedTitle = metadata.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
|
||||
return new NextResponse(readableWebStream, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Disposition': `attachment; filename="${sanitizedTitle}.${format}"`,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error downloading chapter:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to download chapter' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
|
||||
|
||||
if (!bookId || !chapterIndexStr) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing bookId or chapterIndex parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const chapterIndex = parseInt(chapterIndexStr, 10);
|
||||
if (isNaN(chapterIndex)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid chapterIndex parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const docstoreDir = join(process.cwd(), 'docstore');
|
||||
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||
|
||||
// Read metadata to get format (if present)
|
||||
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
||||
|
||||
// Delete the chapter audio file (try both formats just in case)
|
||||
const chapterPathM4b = join(intermediateDir, `${chapterIndex}-chapter.m4b`);
|
||||
const chapterPathMp3 = join(intermediateDir, `${chapterIndex}-chapter.mp3`);
|
||||
if (existsSync(chapterPathM4b)) await unlink(chapterPathM4b).catch(() => {});
|
||||
if (existsSync(chapterPathMp3)) await unlink(chapterPathMp3).catch(() => {});
|
||||
|
||||
// Delete metadata if present
|
||||
if (existsSync(metadataPath)) {
|
||||
await unlink(metadataPath).catch(() => {});
|
||||
}
|
||||
|
||||
// Invalidate any combined "complete" files
|
||||
const completeM4b = join(intermediateDir, `complete.m4b`);
|
||||
const completeMp3 = join(intermediateDir, `complete.mp3`);
|
||||
if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {});
|
||||
if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting chapter:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete chapter' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
98
src/app/api/audio/convert/chapters/route.ts
Normal file
98
src/app/api/audio/convert/chapters/route.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readdir, readFile, rm } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||
if (!bookId) {
|
||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const docstoreDir = join(process.cwd(), 'docstore');
|
||||
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||
|
||||
if (!existsSync(intermediateDir)) {
|
||||
return NextResponse.json({ chapters: [], exists: false });
|
||||
}
|
||||
|
||||
// Read all chapter metadata
|
||||
const files = await readdir(intermediateDir);
|
||||
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
|
||||
const chapters: Array<{
|
||||
index: number;
|
||||
title: string;
|
||||
duration?: number;
|
||||
status: 'completed' | 'error';
|
||||
bookId: string;
|
||||
format?: 'mp3' | 'm4b';
|
||||
}> = [];
|
||||
|
||||
for (const metaFile of metaFiles) {
|
||||
try {
|
||||
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
|
||||
chapters.push({
|
||||
index: meta.index,
|
||||
title: meta.title,
|
||||
duration: meta.duration,
|
||||
status: 'completed',
|
||||
bookId,
|
||||
format: meta.format || 'm4b'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error reading metadata file ${metaFile}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chapters by index
|
||||
chapters.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Check if complete audiobook exists (either format)
|
||||
const format = chapters[0]?.format || 'm4b';
|
||||
const completePath = join(intermediateDir, `complete.${format}`);
|
||||
const hasComplete = existsSync(completePath);
|
||||
|
||||
return NextResponse.json({
|
||||
chapters,
|
||||
exists: true,
|
||||
hasComplete,
|
||||
bookId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching chapters:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch chapters' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||
if (!bookId) {
|
||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const docstoreDir = join(process.cwd(), 'docstore');
|
||||
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||
|
||||
// If directory doesn't exist, consider it already reset
|
||||
if (!existsSync(intermediateDir)) {
|
||||
return NextResponse.json({ success: true, existed: false });
|
||||
}
|
||||
|
||||
// Recursively delete the entire audiobook directory
|
||||
await rm(intermediateDir, { recursive: true, force: true });
|
||||
|
||||
return NextResponse.json({ success: true, existed: true });
|
||||
} catch (error) {
|
||||
console.error('Error resetting audiobook:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reset audiobook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { spawn } from 'child_process';
|
||||
import { writeFile, readFile, mkdir, unlink, rmdir, readdir } from 'fs/promises';
|
||||
import { writeFile, readFile, mkdir, unlink, readdir } from 'fs/promises';
|
||||
import { existsSync, createReadStream } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
|
@ -9,9 +9,11 @@ interface ConversionRequest {
|
|||
chapterTitle: string;
|
||||
buffer: number[];
|
||||
bookId?: string;
|
||||
format?: 'mp3' | 'm4b';
|
||||
chapterIndex?: number;
|
||||
}
|
||||
|
||||
async function getAudioDuration(filePath: string): Promise<number> {
|
||||
async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffprobe = spawn('ffprobe', [
|
||||
'-i', filePath,
|
||||
|
|
@ -21,11 +23,38 @@ async function getAudioDuration(filePath: string): Promise<number> {
|
|||
]);
|
||||
|
||||
let output = '';
|
||||
let finished = false;
|
||||
|
||||
const onAbort = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
try {
|
||||
ffprobe.kill('SIGKILL');
|
||||
} catch {}
|
||||
reject(new Error('ABORTED'));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
ffprobe.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
ffprobe.on('close', (code) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
if (code === 0) {
|
||||
const duration = parseFloat(output.trim());
|
||||
resolve(duration);
|
||||
|
|
@ -35,20 +64,44 @@ async function getAudioDuration(filePath: string): Promise<number> {
|
|||
});
|
||||
|
||||
ffprobe.on('error', (err) => {
|
||||
if (finished) return;
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runFFmpeg(args: string[]): Promise<void> {
|
||||
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ffmpeg = spawn('ffmpeg', args);
|
||||
|
||||
let finished = false;
|
||||
|
||||
const onAbort = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
try {
|
||||
ffmpeg.kill('SIGKILL');
|
||||
} catch {}
|
||||
reject(new Error('ABORTED'));
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
console.error(`ffmpeg stderr: ${data}`);
|
||||
});
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
|
|
@ -57,6 +110,9 @@ async function runFFmpeg(args: string[]): Promise<void> {
|
|||
});
|
||||
|
||||
ffmpeg.on('error', (err) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
|
@ -66,51 +122,84 @@ export async function POST(request: NextRequest) {
|
|||
try {
|
||||
// Parse the request body
|
||||
const data: ConversionRequest = await request.json();
|
||||
const format = data.format || 'm4b';
|
||||
|
||||
// Create temp directory if it doesn't exist
|
||||
const tempDir = join(process.cwd(), 'temp');
|
||||
if (!existsSync(tempDir)) {
|
||||
await mkdir(tempDir);
|
||||
// Use docstore directory
|
||||
const docstoreDir = join(process.cwd(), 'docstore');
|
||||
if (!existsSync(docstoreDir)) {
|
||||
await mkdir(docstoreDir);
|
||||
}
|
||||
|
||||
// Generate or use existing book ID
|
||||
const bookId = data.bookId || randomUUID();
|
||||
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
|
||||
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||
|
||||
// Create intermediate directory
|
||||
if (!existsSync(intermediateDir)) {
|
||||
await mkdir(intermediateDir);
|
||||
}
|
||||
|
||||
// Count existing files to determine chapter index
|
||||
const files = await readdir(intermediateDir);
|
||||
const wavFiles = files.filter(f => f.endsWith('.wav'));
|
||||
const chapterIndex = wavFiles.length;
|
||||
// Use provided chapter index or find the next available index robustly (handles gaps)
|
||||
let chapterIndex: number;
|
||||
if (data.chapterIndex !== undefined) {
|
||||
chapterIndex = data.chapterIndex;
|
||||
} else {
|
||||
const files = await readdir(intermediateDir);
|
||||
const indices = files
|
||||
.map(f => f.match(/^(\d+)-chapter\.(m4b|mp3)$/))
|
||||
.filter((m): m is RegExpMatchArray => Boolean(m))
|
||||
.map(m => parseInt(m[1], 10))
|
||||
.sort((a, b) => a - b);
|
||||
// Find smallest non-negative integer not present
|
||||
let next = 0;
|
||||
for (const idx of indices) {
|
||||
if (idx === next) {
|
||||
next++;
|
||||
} else if (idx > next) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
chapterIndex = next;
|
||||
}
|
||||
|
||||
// Write input file
|
||||
const inputPath = join(intermediateDir, `${chapterIndex}-input.aac`);
|
||||
const outputPath = join(intermediateDir, `${chapterIndex}.wav`);
|
||||
// Write input file (MP3 from TTS)
|
||||
const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`);
|
||||
const chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
|
||||
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
||||
|
||||
// Write the chapter audio to a temp file
|
||||
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
|
||||
|
||||
// Convert to WAV from raw aac with consistent format
|
||||
await runFFmpeg([
|
||||
'-i', inputPath,
|
||||
'-f', 'wav',
|
||||
'-c:a', 'copy',
|
||||
'-preset', 'ultrafast',
|
||||
'-threads', '0',
|
||||
outputPath
|
||||
]);
|
||||
if (format === 'mp3') {
|
||||
// For MP3, re-encode to ensure proper headers and consistent format
|
||||
await runFFmpeg([
|
||||
'-y', // Overwrite output file without asking
|
||||
'-i', inputPath,
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', '64k',
|
||||
'-metadata', `title=${data.chapterTitle}`,
|
||||
chapterOutputPath
|
||||
], request.signal);
|
||||
} else {
|
||||
// Convert MP3 to M4B container with proper encoding and metadata
|
||||
await runFFmpeg([
|
||||
'-y', // Overwrite output file without asking
|
||||
'-i', inputPath,
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '64k',
|
||||
'-metadata', `title=${data.chapterTitle}`,
|
||||
'-f', 'mp4',
|
||||
chapterOutputPath
|
||||
], request.signal);
|
||||
}
|
||||
|
||||
// Get the duration and save metadata
|
||||
const duration = await getAudioDuration(outputPath);
|
||||
const duration = await getAudioDuration(chapterOutputPath, request.signal);
|
||||
await writeFile(metadataPath, JSON.stringify({
|
||||
title: data.chapterTitle,
|
||||
duration,
|
||||
index: chapterIndex
|
||||
index: chapterIndex,
|
||||
format
|
||||
}));
|
||||
|
||||
// Clean up input file
|
||||
|
|
@ -123,9 +212,15 @@ export async function POST(request: NextRequest) {
|
|||
});
|
||||
|
||||
} catch (error) {
|
||||
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
||||
return NextResponse.json(
|
||||
{ error: 'cancelled' },
|
||||
{ status: 499 }
|
||||
);
|
||||
}
|
||||
console.error('Error processing audio chapter:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process audio chapter' },
|
||||
{ error: 'Failed to process audio chapter' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
@ -134,15 +229,13 @@ export async function POST(request: NextRequest) {
|
|||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
|
||||
if (!bookId) {
|
||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tempDir = join(process.cwd(), 'temp');
|
||||
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
|
||||
const outputPath = join(tempDir, `${bookId}.m4b`);
|
||||
const metadataPath = join(tempDir, `${bookId}-metadata.txt`);
|
||||
const listPath = join(tempDir, `${bookId}-list.txt`);
|
||||
const docstoreDir = join(process.cwd(), 'docstore');
|
||||
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||
|
||||
if (!existsSync(intermediateDir)) {
|
||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||
|
|
@ -151,21 +244,39 @@ export async function GET(request: NextRequest) {
|
|||
// Read all chapter metadata
|
||||
const files = await readdir(intermediateDir);
|
||||
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
|
||||
const chapters: { title: string; duration: number; index: number }[] = [];
|
||||
const chapters: { title: string; duration: number; index: number; format: string }[] = [];
|
||||
|
||||
for (const metaFile of metaFiles) {
|
||||
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
|
||||
chapters.push(meta);
|
||||
}
|
||||
|
||||
if (chapters.length === 0) {
|
||||
return NextResponse.json({ error: 'No chapters found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Sort chapters by index
|
||||
chapters.sort((a, b) => a.index - b.index);
|
||||
// Determine output format from existing chapter metadata to avoid mismatches
|
||||
const chapterFormat = (chapters[0]?.format === 'mp3' || chapters[0]?.format === 'm4b') ? chapters[0].format : 'm4b';
|
||||
if (requestedFormat && requestedFormat !== chapterFormat) {
|
||||
console.warn(`Requested format ${requestedFormat} differs from chapter format ${chapterFormat}. Using ${chapterFormat}.`);
|
||||
}
|
||||
const format = chapterFormat;
|
||||
const outputPath = join(intermediateDir, `complete.${format}`);
|
||||
const metadataPath = join(intermediateDir, 'metadata.txt');
|
||||
const listPath = join(intermediateDir, 'list.txt');
|
||||
|
||||
// Create chapter metadata file
|
||||
// Check if combined file already exists
|
||||
if (existsSync(outputPath)) {
|
||||
// Stream the existing file
|
||||
return streamFile(outputPath, format);
|
||||
}
|
||||
|
||||
// Create chapter metadata file for M4B
|
||||
const metadata: string[] = [];
|
||||
let currentTime = 0;
|
||||
|
||||
// Calculate chapter timings based on actual durations
|
||||
chapters.forEach((chapter) => {
|
||||
const startMs = Math.floor(currentTime * 1000);
|
||||
currentTime += chapter.duration;
|
||||
|
|
@ -185,72 +296,86 @@ export async function GET(request: NextRequest) {
|
|||
// Create list file for concat
|
||||
await writeFile(
|
||||
listPath,
|
||||
chapters.map(c => `file '${join(intermediateDir, `${c.index}.wav`)}'`).join('\n')
|
||||
chapters.map(c => `file '${join(intermediateDir, `${c.index}-chapter.${format}`)}'`).join('\n')
|
||||
);
|
||||
|
||||
// Combine all files into a single M4B
|
||||
await runFFmpeg([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', listPath,
|
||||
'-i', metadataPath,
|
||||
'-map_metadata', '1',
|
||||
'-c:a', 'copy', // c:a is codec for audio and :a is stream specifier
|
||||
//'-codec', 'wav',
|
||||
//'-b:a', '192k',
|
||||
//'-threads', '0', // Use maximum available threads
|
||||
//'-movflags', '+faststart',
|
||||
//'-preset', 'ultrafast', // Use fastest encoding preset
|
||||
outputPath
|
||||
if (format === 'mp3') {
|
||||
// For MP3, re-encode to properly rebuild headers and duration metadata
|
||||
// Using libmp3lame to ensure proper MP3 structure
|
||||
await runFFmpeg([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', listPath,
|
||||
'-c:a', 'libmp3lame',
|
||||
'-b:a', '64k',
|
||||
outputPath
|
||||
], request.signal);
|
||||
} else {
|
||||
// Combine all files into a single M4B with chapter metadata
|
||||
await runFFmpeg([
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', listPath,
|
||||
'-i', metadataPath,
|
||||
'-map_metadata', '1',
|
||||
'-c:a', 'copy',
|
||||
'-f', 'mp4',
|
||||
outputPath
|
||||
], request.signal);
|
||||
}
|
||||
|
||||
// Clean up temporary files (but keep the chapters and complete file)
|
||||
await Promise.all([
|
||||
unlink(metadataPath).catch(console.error),
|
||||
unlink(listPath).catch(console.error)
|
||||
]);
|
||||
|
||||
// Stream the file back to the client
|
||||
const stream = createReadStream(outputPath);
|
||||
|
||||
// Clean up function
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
...chapters.map(c => unlink(join(intermediateDir, `${c.index}.wav`))),
|
||||
...chapters.map(c => unlink(join(intermediateDir, `${c.index}.meta.json`))),
|
||||
unlink(metadataPath),
|
||||
unlink(listPath),
|
||||
unlink(outputPath),
|
||||
rmdir(intermediateDir)
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Cleanup error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up after streaming is complete
|
||||
stream.on('end', cleanup);
|
||||
|
||||
const readableWebStream = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(readableWebStream, {
|
||||
headers: {
|
||||
'Content-Type': 'audio/mp4',
|
||||
},
|
||||
});
|
||||
return streamFile(outputPath, format);
|
||||
|
||||
} catch (error) {
|
||||
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
||||
return NextResponse.json(
|
||||
{ error: 'cancelled' },
|
||||
{ status: 499 }
|
||||
);
|
||||
}
|
||||
console.error('Error creating M4B:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create M4B file' },
|
||||
{ error: 'Failed to create M4B file' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to stream file
|
||||
function streamFile(filePath: string, format: string) {
|
||||
const stream = createReadStream(filePath);
|
||||
|
||||
const readableWebStream = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||
|
||||
return new NextResponse(readableWebStream, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Disposition': `attachment; filename="audiobook.${format}"`,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import { useCallback, useEffect, useState } from 'react';
|
|||
import { useHTML } from '@/contexts/HTMLContext';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { HTMLViewer } from '@/components/HTMLViewer';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||
import { SettingsIcon } from '@/components/icons/Icons';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useTTS } from "@/contexts/TTSContext";
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { ZoomControl } from '@/components/ZoomControl';
|
||||
|
||||
export default function HTMLPage() {
|
||||
const { id } = useParams();
|
||||
|
|
@ -18,6 +20,9 @@ export default function HTMLPage() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
||||
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||
|
||||
const loadDocument = useCallback(async () => {
|
||||
if (!isLoading) return;
|
||||
|
|
@ -41,6 +46,29 @@ export default function HTMLPage() {
|
|||
loadDocument();
|
||||
}, [loadDocument]);
|
||||
|
||||
// Compute available height = viewport - (header height + tts bar height)
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
|
||||
const ttsbar = document.querySelector('[data-app-ttsbar]') as HTMLElement | null;
|
||||
const headerH = header ? header.getBoundingClientRect().height : 0;
|
||||
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
||||
const vh = window.innerHeight;
|
||||
const h = Math.max(0, vh - headerH - ttsH);
|
||||
setContainerHeight(`${h}px`);
|
||||
|
||||
// Adaptive minimum content width: allow some padding on narrow screens
|
||||
const vw = window.innerWidth;
|
||||
const desiredMin = 640;
|
||||
const minContent = Math.min(desiredMin, Math.max(320, vw - 32));
|
||||
const maxPad = Math.max(0, Math.floor((vw - minContent) / 2));
|
||||
setMaxPadPx(maxPad);
|
||||
};
|
||||
compute();
|
||||
window.addEventListener('resize', compute);
|
||||
return () => window.removeEventListener('resize', compute);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
|
|
@ -48,7 +76,7 @@ export default function HTMLPage() {
|
|||
<Link
|
||||
href="/"
|
||||
onClick={() => {clearCurrDoc();}}
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
|
|
@ -61,39 +89,52 @@ export default function HTMLPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2 pb-2 border-b border-offbase">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => {clearCurrDoc();}}
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
<Button
|
||||
<Header
|
||||
left={
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => clearCurrDoc()}
|
||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
aria-label="Back to documents"
|
||||
>
|
||||
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
}
|
||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||
right={
|
||||
<div className="flex items-center gap-3">
|
||||
<ZoomControl
|
||||
value={padPct}
|
||||
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
|
||||
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
|
||||
aria-label="View Settings"
|
||||
className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Open settings"
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
||||
</Button>
|
||||
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
||||
{isLoading ? 'Loading...' : currDocName}
|
||||
</h1>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
|
||||
<HTMLViewer className="h-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<HTMLViewer className="p-4" />
|
||||
)}
|
||||
<TTSPlayer />
|
||||
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Providers } from "@/app/providers";
|
|||
import { Metadata } from "next";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenReader WebUI",
|
||||
|
|
@ -56,14 +56,16 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||
</head>
|
||||
<body className="antialiased">
|
||||
<Providers>
|
||||
<div className="min-h-screen bg-background p-4">
|
||||
<div className="relative max-w-6xl mx-auto align-center">
|
||||
<div className="bg-base rounded-lg shadow-lg">
|
||||
{children}
|
||||
<Analytics />
|
||||
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||
<main className="flex-1 flex flex-col">
|
||||
{children}
|
||||
<Analytics />
|
||||
</main>
|
||||
{!isDev && (
|
||||
<div className="px-4 py-3">
|
||||
<Footer />
|
||||
</div>
|
||||
{!isDev && <Footer />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toaster />
|
||||
</Providers>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||
import { DocumentList } from '@/components/doclist/DocumentList';
|
||||
import { HomeContent } from '@/components/HomeContent';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
|
||||
// Home page redesigned for fullscreen layout: hero + document area.
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className='p-3.5 sm:p-5'>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<SettingsModal />
|
||||
<h1 className="text-xl font-bold text-center flex-grow">OpenReader WebUI</h1>
|
||||
<p className="text-sm mt-1 text-center text-muted mb-5">A bring your own text-to-speech api web interface for reading documents with high quality voices</p>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<DocumentUploader className='max-w-xl' />
|
||||
<DocumentList />
|
||||
</div>
|
||||
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
|
||||
<p className="text-sm leading-relaxed max-w-prose text-foreground">
|
||||
Bring your own text-to-speech API.
|
||||
<span className="block font-medium">Read & listen to PDF, EPUB & HTML documents with high quality voices.</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex-1 px-4 pb-8 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
|
||||
<HomeContent />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ import Link from 'next/link';
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||
import { SettingsIcon } from '@/components/icons/Icons';
|
||||
import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
|
||||
import { Header } from '@/components/Header';
|
||||
import { ZoomControl } from '@/components/ZoomControl';
|
||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
|
||||
// Dynamic import for client-side rendering only
|
||||
const PDFViewer = dynamic(
|
||||
|
|
@ -22,12 +25,14 @@ const PDFViewer = dynamic(
|
|||
|
||||
export default function PDFViewerPage() {
|
||||
const { id } = useParams();
|
||||
const { setCurrentDocument, currDocName, clearCurrDoc } = usePDF();
|
||||
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
||||
const { stop } = useTTS();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||
|
||||
const loadDocument = useCallback(async () => {
|
||||
if (!isLoading) return; // Prevent calls when not loading new doc
|
||||
|
|
@ -51,9 +56,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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
733
src/components/AudiobookExportModal.tsx
Normal file
733
src/components/AudiobookExportModal.tsx
Normal 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 "Start Generation" to begin creating your audiobook.
|
||||
<br />
|
||||
Individual chapters will appear here as they are generated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-background px-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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
|
|||
|
||||
interface DocumentUploaderProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||
export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) {
|
||||
const {
|
||||
addPDFDocument: addPDF,
|
||||
addEPUBDocument: addEPUB,
|
||||
|
|
@ -87,41 +88,51 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
|||
disabled: isUploading || isConverting
|
||||
});
|
||||
|
||||
const containerBase = `w-full border-2 border-dashed rounded-lg ${isDragActive ? 'border-accent bg-base' : 'border-muted'} transform transition-transform duration-200 ease-in-out ${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base hover:scale-[1.008]'} ${className}`;
|
||||
const paddingClass = variant === 'compact' ? 'py-1.5 px-2' : 'py-5 px-3';
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`
|
||||
w-full py-5 px-3 border-2 border-dashed rounded-lg
|
||||
${isDragActive ? 'border-accent bg-base' : 'border-muted'}
|
||||
transform trasition-transform duration-200 ease-in-out hover:scale-[1.008]
|
||||
${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base'}
|
||||
${className}
|
||||
`}
|
||||
className={`${containerBase} ${paddingClass}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
||||
|
||||
{isUploading ? (
|
||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
||||
Uploading file...
|
||||
</p>
|
||||
) : isConverting ? (
|
||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
||||
Converting DOCX to PDF...
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
|
||||
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted">
|
||||
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
|
||||
</p>
|
||||
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{variant === 'compact' ? (
|
||||
<div className="flex items-center gap-2 text-left">
|
||||
<UploadIcon className="w-5 h-5 text-muted" />
|
||||
{isUploading ? (
|
||||
<p className="text-xs font-medium text-foreground">Uploading…</p>
|
||||
) : isConverting ? (
|
||||
<p className="text-xs font-medium text-foreground">Converting DOCX…</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{isDragActive ? 'Drop files here' : 'Drop files or click'}
|
||||
</p>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
||||
{isUploading ? (
|
||||
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
|
||||
) : isConverting ? (
|
||||
<p className="text-sm sm:text-lg font-semibold text-foreground">Converting DOCX to PDF...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
|
||||
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted">
|
||||
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
|
||||
</p>
|
||||
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useEPUB } from '@/contexts/EPUBContext';
|
|||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
||||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||
|
||||
|
|
@ -65,11 +64,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
||||
<div className="z-10">
|
||||
<TTSPlayer />
|
||||
</div>
|
||||
<div className="flex-1 -mt-16 pt-16">
|
||||
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
|
||||
<div className="flex-1">
|
||||
<ReactReader
|
||||
loadingView={<DocumentSkeleton />}
|
||||
key={'epub-reader'}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useRef } from 'react';
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useHTML } from '@/contexts/HTMLContext';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
|
||||
interface HTMLViewerProps {
|
||||
|
|
@ -20,24 +19,21 @@ export function HTMLViewer({ className = '' }: HTMLViewerProps) {
|
|||
}
|
||||
|
||||
// Check if the file is a txt file
|
||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
||||
<div className="z-10">
|
||||
<TTSPlayer />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className={`min-w-full px-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
||||
{isTxtFile ? (
|
||||
currDocData
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{currDocData}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className={`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
29
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/HomeContent.tsx
Normal file
24
src/components/HomeContent.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||
import { DocumentList } from '@/components/doclist/DocumentList';
|
||||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
|
||||
export function HomeContent() {
|
||||
const { pdfDocs, epubDocs, htmlDocs } = useDocuments();
|
||||
const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0);
|
||||
|
||||
if (totalDocs === 0) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<DocumentUploader className="py-12" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<DocumentList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
96
src/components/ProgressCard.tsx
Normal file
96
src/components/ProgressCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>•</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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
39
src/components/ZoomControl.tsx
Normal file
39
src/components/ZoomControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { DragEvent, useState } from 'react';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { LoadingSpinner } from '@/components/Spinner';
|
||||
import { DocumentListDocument } from '@/types/documents';
|
||||
|
||||
interface DocumentListItemProps {
|
||||
|
|
@ -50,41 +49,44 @@ export function DocumentListItem({
|
|||
onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)}
|
||||
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
||||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||
aria-busy={loading}
|
||||
className={`
|
||||
w-full
|
||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent bg-primary/10' : ''}
|
||||
bg-background rounded-lg p-2 shadow hover:shadow-md transition-shadow
|
||||
relative
|
||||
w-full group
|
||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
||||
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
||||
border border-offbase rounded-md p-1
|
||||
transition-colors duration-150 relative
|
||||
`}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/80 rounded-lg">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
draggable={false}
|
||||
className="document-link flex items-center align-center space-x-4 w-full truncate hover:bg-base rounded-lg p-0.5 sm:p-1 transition-colors"
|
||||
className="document-link flex items-center align-center gap-2 w-full truncate rounded-md py-0.5 px-0.5"
|
||||
onClick={handleDocumentClick}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{doc.type === 'pdf' ? <PDFIcon /> : doc.type === 'epub' ? <EPUBIcon /> : <FileIcon />}
|
||||
{doc.type === 'pdf' ? (
|
||||
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
|
||||
) : doc.type === 'epub' ? (
|
||||
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
|
||||
) : (
|
||||
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
|
||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
||||
<p className="text-xs sm:text-sm text-muted truncate">
|
||||
<div className="flex flex-col min-w-0 transform transition-transform duration-150 ease-in-out hover:scale-[1.009] w-full truncate">
|
||||
<p className="text-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">{doc.name}</p>
|
||||
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
|
||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={() => onDelete(doc)}
|
||||
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
|
||||
className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors"
|
||||
aria-label="Delete document"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -29,13 +29,13 @@ export function SortControls({
|
|||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={onSortDirectionChange}
|
||||
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap"
|
||||
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
>
|
||||
{directionLabel}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</ListboxButton>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
export const PlayIcon = () => (
|
||||
export const PlayIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="16"
|
||||
|
|
@ -22,13 +24,15 @@ export const PlayIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export const PauseIcon = () => (
|
||||
export const PauseIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="16"
|
||||
|
|
@ -59,13 +63,15 @@ export const PauseIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export const SkipForwardIcon = () => (
|
||||
export const SkipForwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M6 4l10 8-10 8V4z"
|
||||
|
|
@ -84,13 +90,15 @@ export const SkipForwardIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export const SkipBackwardIcon = () => (
|
||||
export const SkipBackwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
width={props.width || 24}
|
||||
height={props.height || 24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M18 4L8 12l10 8V4z"
|
||||
|
|
@ -262,3 +270,147 @@ export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckCircleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XCircleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClockIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zM12.75 6a.75.75 0 00-1.5 0v6c0 .414.336.75.75.75h4.5a.75.75 0 000-1.5h-3.75V6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<circle cx="12" cy="5" r="1.5" />
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
<circle cx="12" cy="19" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"></path>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AudioWaveIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path d="M6 9.85986V14.1499" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
<path d="M9 8.42993V15.5699" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
<path d="M12 7V17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
<path d="M15 8.42993V15.5699" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
<path d="M18 9.85986V14.1499" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
<Button
|
||||
onClick={() => skipToLocation(currentPage - 1, true)}
|
||||
disabled={currentPage <= 1}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
|
|
@ -82,7 +82,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
return (
|
||||
<>
|
||||
<PopoverButton
|
||||
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase/80"
|
||||
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
onClick={handlePopoverOpen}
|
||||
>
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
|
|
@ -105,7 +105,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
placeholder={currentPage.toString()}
|
||||
aria-label="Page number"
|
||||
/>
|
||||
<div className="text-xs text-foreground/70 text-center">of {numPages || 1}</div>
|
||||
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</>
|
||||
|
|
@ -117,7 +117,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
<Button
|
||||
onClick={() => skipToLocation(currentPage + 1, true)}
|
||||
disabled={currentPage >= (numPages || 1)}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -54,7 +54,8 @@ export const SpeedControl = ({
|
|||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
||||
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</PopoverButton>
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
} = useTTS();
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
|
||||
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100 border border-offbase">
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
|
||||
{/* Speed control */}
|
||||
<SpeedControl
|
||||
setSpeedAndRestart={setSpeedAndRestart}
|
||||
|
|
@ -51,29 +51,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
{/* Playback Controls */}
|
||||
<Button
|
||||
onClick={skipBackward}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Skip backward"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={togglePlay}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
|
||||
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={skipForward}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
||||
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Skip forward"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Voice control */}
|
||||
|
|
|
|||
|
|
@ -6,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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const getThemeStyles = (): IReactReaderStyle => {
|
|||
readerArea: {
|
||||
...baseStyle.readerArea,
|
||||
backgroundColor: colors.base,
|
||||
height: '100%',
|
||||
},
|
||||
titleArea: {
|
||||
...baseStyle.titleArea,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export default {
|
|||
base: "var(--base)",
|
||||
offbase: "var(--offbase)",
|
||||
accent: "var(--accent)",
|
||||
"secondary-accent": "var(--secondary-accent)",
|
||||
muted: "var(--muted)",
|
||||
},
|
||||
animation: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue