From ff150eec0125099fd56bba1f3846f16d5ca76944 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Sun, 16 Nov 2025 21:10:32 -0700 Subject: [PATCH] refactor(audiobook): standardize chapter regeneration APIs and UX - Removed `onProgress` callback from `regenerateChapter` and related functions across `TTS`, `EPUB`, and `PDF` contexts to simplify the chapter regeneration API. - Updated `AudiobookExportModal` to align with the refined regeneration API, including removing granular chapter progress display and adding a hint about TTS caching behavior. - Introduced `TTSAudiobookChapter` interface and renamed `ContinuationMergeResult` to `TTSSmartMergeResult` and `PageTurnEstimate` to `TTSPageTurnEstimate` for better type consistency and clarity. - Applied minor styling adjustments to buttons and listbox components in modals for visual consistency. - Added `'use client'` directive to several client-side components for Next.js 13+ compatibility. - Updated Dockerfile build command from `pnpm run build` to `pnpm build`. - Added Playwright tests to verify backend chapter state after regeneration. --- Dockerfile | 2 +- src/app/epub/[id]/page.tsx | 3 +- src/app/globals.css | 4 +- src/app/pdf/[id]/page.tsx | 3 +- src/components/AudiobookExportModal.tsx | 577 +++++++++++----------- src/components/ConfirmDialog.tsx | 10 +- src/components/DocumentSettings.tsx | 25 +- src/components/Header.tsx | 2 - src/components/SettingsModal.tsx | 36 +- src/components/ZoomControl.tsx | 2 - src/components/doclist/DocumentFolder.tsx | 2 + src/contexts/EPUBContext.tsx | 3 +- src/contexts/PDFContext.tsx | 3 +- src/contexts/TTSContext.tsx | 12 +- src/types/tts.ts | 37 +- tests/export.spec.ts | 10 + 16 files changed, 384 insertions(+), 347 deletions(-) diff --git a/Dockerfile b/Dockerfile index 57f41ef..52a26f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ COPY . . # Build the Next.js application RUN pnpm exec next telemetry disable -RUN pnpm run build +RUN pnpm build # Expose the port the app runs on EXPOSE 3003 diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 4995191..07becf3 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -94,10 +94,9 @@ export default function EPUBPage() { chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', - onProgress: (progress: number) => void, signal: AbortSignal ) => { - return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal); + return regenerateEPUBChapter(chapterIndex, bookId, format, signal); }, [regenerateEPUBChapter]); if (error) { diff --git a/src/app/globals.css b/src/app/globals.css index d2f3ff3..10bc47f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -10,7 +10,7 @@ html.light { --base: #f7fafc; --offbase: #e2e8f0; --accent: #ef4444; - --secondary-accent: #3b82f6; + --secondary-accent: #ed6868; --muted: #718096; --prism-gradient: linear-gradient(90deg, #fecaca, @@ -25,7 +25,7 @@ html.dark { --base: #171717; --offbase: #343434; --accent: #f87171; - --secondary-accent: #60a5fa; + --secondary-accent: #eb6262; --muted: #a3a3a3; --prism-gradient: linear-gradient(90deg, #fca5a5, diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 532003c..b08fc88 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -88,10 +88,9 @@ export default function PDFViewerPage() { chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', - onProgress: (progress: number) => void, signal: AbortSignal ) => { - return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal); + return regeneratePDFChapter(chapterIndex, bookId, format, signal); }, [regeneratePDFChapter]); if (error) { diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index 769460f..c36970c 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Fragment, useState, useRef, useCallback, useEffect } from 'react'; import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; @@ -7,16 +9,7 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco 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'; -} - +import type { TTSAudiobookChapter } from '@/types/tts'; interface AudiobookExportModalProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; @@ -25,16 +18,15 @@ interface AudiobookExportModalProps { onGenerateAudiobook: ( onProgress: (progress: number) => void, signal: AbortSignal, - onChapterComplete: (chapter: AudiobookChapter) => void, + onChapterComplete: (chapter: TTSAudiobookChapter) => void, format: 'mp3' | 'm4b' ) => Promise; // Returns bookId onRegenerateChapter?: ( chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', - onProgress: (progress: number) => void, signal: AbortSignal - ) => Promise; + ) => Promise; } export function AudiobookExportModal({ @@ -48,7 +40,7 @@ export function AudiobookExportModal({ const { isLoading, isDBReady } = useConfig(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const [isGenerating, setIsGenerating] = useState(false); - const [chapters, setChapters] = useState([]); + const [chapters, setChapters] = useState([]); const [bookId, setBookId] = useState(null); const [isCombining, setIsCombining] = useState(false); const [isLoadingExisting, setIsLoadingExisting] = useState(false); @@ -56,11 +48,11 @@ export function AudiobookExportModal({ const [currentChapter, setCurrentChapter] = useState(''); const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b'); const [regeneratingChapter, setRegeneratingChapter] = useState(null); - const [regenerationProgress, setRegenerationProgress] = useState(0); const abortControllerRef = useRef(null); - const [pendingDeleteChapter, setPendingDeleteChapter] = useState(null); + const [pendingDeleteChapter, setPendingDeleteChapter] = useState(null); const [showResetConfirm, setShowResetConfirm] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [showRegenerateHint, setShowRegenerateHint] = useState(false); const fetchExistingChapters = useCallback(async (soft: boolean = false) => { if (soft) { @@ -108,7 +100,7 @@ export function AudiobookExportModal({ } }, [isOpen, documentId, isGenerating, fetchExistingChapters]); - const handleChapterComplete = useCallback((chapter: AudiobookChapter) => { + const handleChapterComplete = useCallback((chapter: TTSAudiobookChapter) => { setChapters(prev => { const existing = prev.find(c => c.index === chapter.index); if (existing) { @@ -183,11 +175,14 @@ export function AudiobookExportModal({ }; }, []); - const handleRegenerateChapter = useCallback(async (chapter: AudiobookChapter) => { + const handleRegenerateChapter = useCallback(async (chapter: TTSAudiobookChapter) => { if (!onRegenerateChapter || !bookId) return; + if (!showRegenerateHint) { + setShowRegenerateHint(true); + } + setRegeneratingChapter(chapter.index); - setRegenerationProgress(0); setCurrentChapter(`Regenerating: ${chapter.title}`); abortControllerRef.current = new AbortController(); @@ -210,10 +205,6 @@ export function AudiobookExportModal({ chapter.index, bookId, format, - (progress) => { - setRegenerationProgress(progress); - setProgress(progress); - }, abortControllerRef.current.signal ); @@ -239,14 +230,13 @@ export function AudiobookExportModal({ } } 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]); + }, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters, showRegenerateHint]); const performDeleteChapter = useCallback(async () => { if (!bookId || !pendingDeleteChapter) return; @@ -287,7 +277,7 @@ export function AudiobookExportModal({ } }, [bookId, documentId, setProgress, fetchExistingChapters]); - const handleDownloadChapter = useCallback(async (chapter: AudiobookChapter) => { + const handleDownloadChapter = useCallback(async (chapter: TTSAudiobookChapter) => { if (!chapter.bookId) return; try { @@ -357,24 +347,25 @@ export function AudiobookExportModal({ // 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[] = + const displayChapters: TTSAudiobookChapter[] = maxIndex >= 0 ? Array.from({ length: maxIndex + 1 }, (_, i) => { - const existing = chapters.find(c => c.index === i); - if (existing) return existing; - return { - index: i, - title: documentType === 'pdf' ? `Page ${i + 1}` : `Chapter ${i + 1}`, - status: 'pending', - bookId: bookId || undefined, - format - }; - }) + 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); + // Determine if we should show the Resume and Reset buttons + const hasAnyChapters = chapters.length > 0; + const showResumeButton = !isGenerating && !regeneratingChapter && hasAnyChapters; + const showResetButton = !isGenerating && !regeneratingChapter && hasAnyChapters; // Do not render until storage/config is initialized if (isLoading || !isDBReady) { @@ -383,7 +374,7 @@ export function AudiobookExportModal({ return ( <> - c.status === 'completed').length} /> - + setIsOpen(false)}> ) : ( <> -
-
-

Export Audiobook

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

Export Audiobook

+ {!isGenerating && ( +
+ {chapters.length === 0 && ( + setFormat(newFormat)} + disabled={chapters.length > 0} + > +
+ + {format.toUpperCase()} + + + + + + + + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + + M4B + + )} + + + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + + MP3 + + )} + + + +
+
+ )} + {chapters.length === 0 && ( + )} + {showResumeButton && ( + + )} + {showResetButton && ( - - )} -
- )} -
- {/* Progress Info */} - {isGenerating && ( - c.status === 'completed').length} - cancelText="Cancel" - /> - )} - - {chapters.length > 0 && ( - <> -
-
-

Chapters

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

- {chapter.title} -

-

- {chapter.status !== 'completed' && Missing • }Duration: {formatDuration(chapter.duration)} -

-
-
-
- {((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && ( - - - - - - - {onRegenerateChapter && !isGenerating && ( - - {({ active, disabled }) => ( - - )} - - )} - {chapter.status === 'completed' && ( - <> - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - - - )} - - - - )} -
+ )}
- ))} + )}
- - {bookId && !isGenerating && ( -
+ {showRegenerateHint && ( +
+

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

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

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

+ {chapters.length > 0 && ( + <> +
+
+

Chapters

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

+ {chapter.title} +

+

+

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

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

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

+
+ )}
- )} -
-
- -
- + onClick={() => setIsOpen(false)} + > + Close + +
+ )} @@ -710,7 +733,7 @@ export function AudiobookExportModal({ 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." + message="Reset audiobook? This deletes all generated chapters/pages and any combined files. This cannot be undone." confirmText="Reset" cancelText="Cancel" isDangerous diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 1672165..a460afb 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -75,8 +75,8 @@ export function ConfirmDialog({
@@ -425,7 +425,7 @@ export function SettingsModal() { } }} > - + {ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'} @@ -444,8 +444,8 @@ export function SettingsModal() { - `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ - active ? 'bg-accent/10 text-accent' : 'text-foreground' + `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${ + active ? 'bg-offbase text-accent' : 'text-foreground' }` } value={model} @@ -477,7 +477,7 @@ export function SettingsModal() { setModelValue(e.target.value); }} placeholder="Enter custom model name" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" /> )}
@@ -490,7 +490,7 @@ export function SettingsModal() { value={localTTSInstructions} onChange={(e) => setLocalTTSInstructions(e.target.value)} placeholder="Enter instructions for the TTS model" - className="w-full h-24 rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + className="w-full h-24 rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" />
)} @@ -507,7 +507,7 @@ export function SettingsModal() { value={localBaseUrl} onChange={(e) => handleInputChange('baseUrl', e.target.value)} placeholder="Using environment variable" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" /> @@ -517,7 +517,7 @@ export function SettingsModal() {