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.
This commit is contained in:
Richard Roberson 2025-11-16 21:10:32 -07:00
parent 04def62ff5
commit ff150eec01
16 changed files with 384 additions and 347 deletions

View file

@ -21,7 +21,7 @@ COPY . .
# Build the Next.js application # Build the Next.js application
RUN pnpm exec next telemetry disable RUN pnpm exec next telemetry disable
RUN pnpm run build RUN pnpm build
# Expose the port the app runs on # Expose the port the app runs on
EXPOSE 3003 EXPOSE 3003

View file

@ -94,10 +94,9 @@ export default function EPUBPage() {
chapterIndex: number, chapterIndex: number,
bookId: string, bookId: string,
format: 'mp3' | 'm4b', format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal signal: AbortSignal
) => { ) => {
return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal); return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
}, [regenerateEPUBChapter]); }, [regenerateEPUBChapter]);
if (error) { if (error) {

View file

@ -10,7 +10,7 @@ html.light {
--base: #f7fafc; --base: #f7fafc;
--offbase: #e2e8f0; --offbase: #e2e8f0;
--accent: #ef4444; --accent: #ef4444;
--secondary-accent: #3b82f6; --secondary-accent: #ed6868;
--muted: #718096; --muted: #718096;
--prism-gradient: linear-gradient(90deg, --prism-gradient: linear-gradient(90deg,
#fecaca, #fecaca,
@ -25,7 +25,7 @@ html.dark {
--base: #171717; --base: #171717;
--offbase: #343434; --offbase: #343434;
--accent: #f87171; --accent: #f87171;
--secondary-accent: #60a5fa; --secondary-accent: #eb6262;
--muted: #a3a3a3; --muted: #a3a3a3;
--prism-gradient: linear-gradient(90deg, --prism-gradient: linear-gradient(90deg,
#fca5a5, #fca5a5,

View file

@ -88,10 +88,9 @@ export default function PDFViewerPage() {
chapterIndex: number, chapterIndex: number,
bookId: string, bookId: string,
format: 'mp3' | 'm4b', format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal signal: AbortSignal
) => { ) => {
return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal); return regeneratePDFChapter(chapterIndex, bookId, format, signal);
}, [regeneratePDFChapter]); }, [regeneratePDFChapter]);
if (error) { if (error) {

View file

@ -1,3 +1,5 @@
'use client';
import { Fragment, useState, useRef, useCallback, useEffect } from 'react'; 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 { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { useTimeEstimation } from '@/hooks/useTimeEstimation';
@ -7,16 +9,7 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import type { TTSAudiobookChapter } from '@/types/tts';
interface AudiobookChapter {
index: number;
title: string;
duration?: number;
status: 'pending' | 'generating' | 'completed' | 'error';
bookId?: string;
format?: 'mp3' | 'm4b';
}
interface AudiobookExportModalProps { interface AudiobookExportModalProps {
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
@ -25,16 +18,15 @@ interface AudiobookExportModalProps {
onGenerateAudiobook: ( onGenerateAudiobook: (
onProgress: (progress: number) => void, onProgress: (progress: number) => void,
signal: AbortSignal, signal: AbortSignal,
onChapterComplete: (chapter: AudiobookChapter) => void, onChapterComplete: (chapter: TTSAudiobookChapter) => void,
format: 'mp3' | 'm4b' format: 'mp3' | 'm4b'
) => Promise<string>; // Returns bookId ) => Promise<string>; // Returns bookId
onRegenerateChapter?: ( onRegenerateChapter?: (
chapterIndex: number, chapterIndex: number,
bookId: string, bookId: string,
format: 'mp3' | 'm4b', format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal signal: AbortSignal
) => Promise<AudiobookChapter>; ) => Promise<TTSAudiobookChapter>;
} }
export function AudiobookExportModal({ export function AudiobookExportModal({
@ -48,7 +40,7 @@ export function AudiobookExportModal({
const { isLoading, isDBReady } = useConfig(); const { isLoading, isDBReady } = useConfig();
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [chapters, setChapters] = useState<AudiobookChapter[]>([]); const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]);
const [bookId, setBookId] = useState<string | null>(null); const [bookId, setBookId] = useState<string | null>(null);
const [isCombining, setIsCombining] = useState(false); const [isCombining, setIsCombining] = useState(false);
const [isLoadingExisting, setIsLoadingExisting] = useState(false); const [isLoadingExisting, setIsLoadingExisting] = useState(false);
@ -56,11 +48,11 @@ export function AudiobookExportModal({
const [currentChapter, setCurrentChapter] = useState<string>(''); const [currentChapter, setCurrentChapter] = useState<string>('');
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b'); const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null); const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
const [regenerationProgress, setRegenerationProgress] = useState<number>(0);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<AudiobookChapter | null>(null); const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showRegenerateHint, setShowRegenerateHint] = useState(false);
const fetchExistingChapters = useCallback(async (soft: boolean = false) => { const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
if (soft) { if (soft) {
@ -108,7 +100,7 @@ export function AudiobookExportModal({
} }
}, [isOpen, documentId, isGenerating, fetchExistingChapters]); }, [isOpen, documentId, isGenerating, fetchExistingChapters]);
const handleChapterComplete = useCallback((chapter: AudiobookChapter) => { const handleChapterComplete = useCallback((chapter: TTSAudiobookChapter) => {
setChapters(prev => { setChapters(prev => {
const existing = prev.find(c => c.index === chapter.index); const existing = prev.find(c => c.index === chapter.index);
if (existing) { 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 (!onRegenerateChapter || !bookId) return;
if (!showRegenerateHint) {
setShowRegenerateHint(true);
}
setRegeneratingChapter(chapter.index); setRegeneratingChapter(chapter.index);
setRegenerationProgress(0);
setCurrentChapter(`Regenerating: ${chapter.title}`); setCurrentChapter(`Regenerating: ${chapter.title}`);
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
@ -210,10 +205,6 @@ export function AudiobookExportModal({
chapter.index, chapter.index,
bookId, bookId,
format, format,
(progress) => {
setRegenerationProgress(progress);
setProgress(progress);
},
abortControllerRef.current.signal abortControllerRef.current.signal
); );
@ -239,14 +230,13 @@ export function AudiobookExportModal({
} }
} finally { } finally {
setRegeneratingChapter(null); setRegeneratingChapter(null);
setRegenerationProgress(0);
setCurrentChapter(''); setCurrentChapter('');
setProgress(0); setProgress(0);
abortControllerRef.current = null; abortControllerRef.current = null;
// Refresh chapters to get updated data (soft refresh list only) // Refresh chapters to get updated data (soft refresh list only)
await fetchExistingChapters(true); await fetchExistingChapters(true);
} }
}, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters]); }, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters, showRegenerateHint]);
const performDeleteChapter = useCallback(async () => { const performDeleteChapter = useCallback(async () => {
if (!bookId || !pendingDeleteChapter) return; if (!bookId || !pendingDeleteChapter) return;
@ -287,7 +277,7 @@ export function AudiobookExportModal({
} }
}, [bookId, documentId, setProgress, fetchExistingChapters]); }, [bookId, documentId, setProgress, fetchExistingChapters]);
const handleDownloadChapter = useCallback(async (chapter: AudiobookChapter) => { const handleDownloadChapter = useCallback(async (chapter: TTSAudiobookChapter) => {
if (!chapter.bookId) return; if (!chapter.bookId) return;
try { try {
@ -357,24 +347,25 @@ export function AudiobookExportModal({
// Compute display list including gaps before the highest existing index // Compute display list including gaps before the highest existing index
const maxIndex = chapters.length > 0 ? Math.max(...chapters.map(c => c.index)) : -1; const maxIndex = chapters.length > 0 ? Math.max(...chapters.map(c => c.index)) : -1;
const displayChapters: AudiobookChapter[] = const displayChapters: TTSAudiobookChapter[] =
maxIndex >= 0 maxIndex >= 0
? Array.from({ length: maxIndex + 1 }, (_, i) => { ? Array.from({ length: maxIndex + 1 }, (_, i) => {
const existing = chapters.find(c => c.index === i); const existing = chapters.find(c => c.index === i);
if (existing) return existing; if (existing) return existing;
return { return {
index: i, index: i,
title: documentType === 'pdf' ? `Page ${i + 1}` : `Chapter ${i + 1}`, title: documentType === 'pdf' ? `Page ${i + 1}` : `Chapter ${i + 1}`,
status: 'pending', status: 'pending',
bookId: bookId || undefined, bookId: bookId || undefined,
format format
}; };
}) })
: []; : [];
// Determine if we should show the Resume button // Determine if we should show the Resume and Reset buttons
const hasIncompleteOrMissing = displayChapters.some(c => c.status !== 'completed'); const hasAnyChapters = chapters.length > 0;
const showResumeButton = !isGenerating && chapters.length > 0 && (progress < 100 || hasIncompleteOrMissing); const showResumeButton = !isGenerating && !regeneratingChapter && hasAnyChapters;
const showResetButton = !isGenerating && !regeneratingChapter && hasAnyChapters;
// Do not render until storage/config is initialized // Do not render until storage/config is initialized
if (isLoading || !isDBReady) { if (isLoading || !isDBReady) {
@ -383,7 +374,7 @@ export function AudiobookExportModal({
return ( return (
<> <>
<ProgressPopup <ProgressPopup
isOpen={isGenerating && !isOpen} isOpen={isGenerating && !isOpen}
progress={progress} progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined} estimatedTimeRemaining={estimatedTimeRemaining || undefined}
@ -395,7 +386,7 @@ export function AudiobookExportModal({
totalChapters={documentType === 'epub' ? undefined : undefined} totalChapters={documentType === 'epub' ? undefined : undefined}
completedChapters={chapters.filter(c => c.status === 'completed').length} completedChapters={chapters.filter(c => c.status === 'completed').length}
/> />
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}> <Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<TransitionChild <TransitionChild
@ -428,264 +419,296 @@ export function AudiobookExportModal({
</div> </div>
) : ( ) : (
<> <>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center gap-3"> <div className="flex justify-between items-center gap-3">
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3> <h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
{!isGenerating && ( {!isGenerating && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{chapters.length === 0 && ( {chapters.length === 0 && (
<Listbox <Listbox
value={format} value={format}
onChange={(newFormat) => setFormat(newFormat)} onChange={(newFormat) => setFormat(newFormat)}
disabled={chapters.length > 0} disabled={chapters.length > 0}
> >
<div className="relative"> <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]"> <ListboxButton className="relative cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[100px]">
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span> <span className="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"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" /> <ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span> </span>
</ListboxButton> </ListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" 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"> <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 <ListboxOption
value="m4b" value="m4b"
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${ `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
active ? 'bg-accent/10 text-accent' : 'text-foreground' }`
}` }
} >
> {({ selected }) => (
{({ selected }) => ( <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> M4B
M4B </span>
</span> )}
)} </ListboxOption>
</ListboxOption> <ListboxOption
<ListboxOption value="mp3"
value="mp3" className={({ active }) =>
className={({ active }) => `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
`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'}`}>
{({ selected }) => ( MP3
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> </span>
MP3 )}
</span> </ListboxOption>
)} </ListboxOptions>
</ListboxOption> </Transition>
</ListboxOptions> </div>
</Transition> </Listbox>
</div> )}
</Listbox> {chapters.length === 0 && (
)}
{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 <Button
onClick={handleStartGeneration} onClick={handleStartGeneration}
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:opacity-95 focus:outline-none font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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]" transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Start Generation
</Button>
)}
{showResumeButton && (
<Button
onClick={handleStartGeneration}
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
> >
Resume Resume
</Button> </Button>
)}
{showResetButton && (
<Button <Button
onClick={() => setShowResetConfirm(true)} onClick={() => setShowResetConfirm(true)}
disabled={isGenerating} disabled={isGenerating}
className="inline-flex justify-center rounded-lg bg-red-500 px-4 py-2 text-sm className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
font-medium text-background hover:opacity-95 focus:outline-none font-medium text-background hover:bg-red-500/90 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 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]" transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
title="Delete all generated chapters/pages for this document" title="Delete all generated chapters/pages for this document"
> >
Reset Reset
</Button> </Button>
</> )}
)}
</div>
)}
</div>
{/* Progress Info */}
{isGenerating && (
<ProgressCard
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={handleCancel}
operationType="audiobook"
currentChapter={currentChapter}
completedChapters={chapters.filter(c => c.status === 'completed').length}
cancelText="Cancel"
/>
)}
{chapters.length > 0 && (
<>
<div className={`space-y-2 max-h-96 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>
))} )}
</div> </div>
{showRegenerateHint && (
{bookId && !isGenerating && ( <div className="flex items-start justify-between bg-offbase border border-offbase rounded-md px-3 py-2 text-xs sm:text-sm">
<div className="pt-4 border-t border-offbase"> <p className="text-xs sm:text-sm text-foreground">
TTS audio for this chapter may be cached
<br />
Change the TTS playback options or restart the server to force uncached regeneration
</p>
<Button <Button
onClick={handleDownloadComplete} onClick={() => setShowRegenerateHint(false)}
disabled={isCombining} className="ml-3 p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-4 py-3 text-sm aria-label="Dismiss regenerate hint"
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" /> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button> </Button>
</div> </div>
)} )}
</> {/* Progress Info */}
)} {isGenerating && (
<ProgressCard
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={handleCancel}
operationType="audiobook"
currentChapter={currentChapter}
completedChapters={chapters.filter(c => c.status === 'completed').length}
cancelText="Cancel"
/>
)}
{chapters.length === 0 && !isGenerating && !isLoadingExisting && ( {chapters.length > 0 && (
<div className="text-center py-8"> <>
<p className="text-sm text-muted"> <div className={`space-y-2 max-h-96 ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`} aria-busy={isRefreshingChapters}>
Click &quot;Start Generation&quot; to begin creating your audiobook. <div className="flex items-center gap-2">
<br /> <h4 className="text-sm font-medium text-foreground">Chapters</h4>
Individual chapters will appear here as they are generated. {isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />}
</p> </div>
{displayChapters.map((chapter) => (
<div
key={chapter.index}
className={`flex items-center justify-between px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
>
<div className="flex items-center space-x-3 flex-1">
{chapter.status === 'completed' ? (
<CheckCircleIcon className="h-5 w-5 text-accent" />
) : onRegenerateChapter ? (
<Button
onClick={() => handleRegenerateChapter(chapter)}
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
className="inline-flex items-center justify-center rounded-full bg-offbase text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] disabled:opacity-50 disabled:cursor-not-allowed"
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
</Button>
) : (
<ClockIcon className="h-5 w-5 text-muted" />
)}
<div className="flex flex-row flex-wrap items-center gap-1">
<p className="text-sm font-medium text-foreground">
{chapter.title}
</p>
<p></p>
<p className="text-xs text-muted mt-0.5">
{chapter.status !== 'completed' && <span className="text-warning">Missing </span>}{formatDuration(chapter.duration)}
</p>
</div>
</div>
<div className="flex items-center">
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
<Menu as="div" className="relative inline-block text-left">
<MenuButton
className="inline-flex items-center justify-center rounded-md p-1.5 hover:bg-background focus:outline-none focus-visible:ring-2 focus-visible:ring-accent text-muted hover:text-foreground transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
title="Chapter actions"
>
<DotsVerticalIcon className="h-5 w-5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems className="absolute right-0 bottom-full mb-2 w-44 origin-bottom-right rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-10 p-1">
{chapter.status === 'completed' && (
<>
<MenuItem>
{({ active }) => (
<button
onClick={() => setPendingDeleteChapter(chapter)}
className={`${active ? 'bg-offbase' : ''} text-red-500 group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
title="Delete this chapter"
>
<XCircleIcon className="h-4 w-4" />
<span>Delete</span>
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
onClick={() => handleDownloadChapter(chapter)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
>
<DownloadIcon className="h-4 w-4" />
<span>Download</span>
</button>
)}
</MenuItem>
</>
)}
{regeneratingChapter === chapter.index && (
<MenuItem>
{({ active }) => (
<button
onClick={handleCancel}
className={`${active ? 'bg-offbase text-red-500' : 'text-red-500'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
title="Cancel this chapter regeneration"
>
<XCircleIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
)}
</MenuItem>
)}
{onRegenerateChapter && !isGenerating && (
<MenuItem disabled={regeneratingChapter !== null}>
{({ active, disabled }) => (
<button
onClick={() => handleRegenerateChapter(chapter)}
disabled={disabled}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed`}
title="Regenerate this chapter"
>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
</button>
)}
</MenuItem>
)}
</MenuItems>
{/* end of menu items */}
</Transition>
</Menu>
)}
</div>
</div>
))}
</div>
{bookId && !isGenerating && (
<div className="pt-4 border-t border-offbase">
<Button
onClick={handleDownloadComplete}
disabled={isCombining}
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-3 py-1.5 text-sm
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
>
<DownloadIcon className="h-5 w-5" />
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
</Button>
</div>
)}
</>
)}
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
<div className="text-center py-8">
<p className="text-sm text-muted">
Click &quot;Start Generation&quot; to begin creating your audiobook.
<br />
Individual chapters will appear here as they are generated.
</p>
</div>
)}
</div> </div>
)}
</div>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button <Button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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" transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
> >
Close Close
</Button> </Button>
</div> </div>
</> </>
)} )}
</DialogPanel> </DialogPanel>
</TransitionChild> </TransitionChild>
@ -710,7 +733,7 @@ export function AudiobookExportModal({
onClose={() => setShowResetConfirm(false)} onClose={() => setShowResetConfirm(false)}
onConfirm={performResetAll} onConfirm={performResetAll}
title="Reset Audiobook" 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" confirmText="Reset"
cancelText="Cancel" cancelText="Cancel"
isDangerous isDangerous

View file

@ -75,8 +75,8 @@ export function ConfirmDialog({
<div className="mt-6 flex justify-end space-x-3"> <div className="mt-6 flex justify-end space-x-3">
<button <button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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" transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={onClose} onClick={onClose}
@ -85,12 +85,12 @@ export function ConfirmDialog({
</button> </button>
<button <button
type="button" type="button"
className={`inline-flex justify-center rounded-lg px-4 py-2 text-sm className={`inline-flex justify-center rounded-lg px-3 py-1.5 text-sm
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 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] transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
${isDangerous ${isDangerous
? 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent' ? 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
: 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent' : 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
}`} }`}
onClick={onConfirm} onClick={onConfirm}
> >

View file

@ -11,7 +11,7 @@ import { useParams } from 'next/navigation';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
const viewTypes = [ const viewTypeTextMapping = [
{ id: 'single', name: 'Single Page' }, { id: 'single', name: 'Single Page' },
{ id: 'dual', name: 'Two Pages' }, { id: 'dual', name: 'Two Pages' },
{ id: 'scroll', name: 'Continuous Scroll' }, { id: 'scroll', name: 'Continuous Scroll' },
@ -45,7 +45,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
right: rightMargin right: rightMargin
}); });
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0]; const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
// Sync local margins with global state // Sync local margins with global state
useEffect(() => { useEffect(() => {
@ -91,13 +91,12 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
chapterIndex: number, chapterIndex: number,
bookId: string, bookId: string,
format: 'mp3' | 'm4b', format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal signal: AbortSignal
) => { ) => {
if (epub) { if (epub) {
return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal); return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
} else { } else {
return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal); return regeneratePDFChapter(chapterIndex, bookId, format, signal);
} }
}, [epub, regenerateEPUBChapter, regeneratePDFChapter]); }, [epub, regenerateEPUBChapter, regeneratePDFChapter]);
@ -141,10 +140,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
{isDev && !html && <div className="space-y-2 mb-4"> {isDev && !html && <div className="space-y-2 mb-4">
<Button <Button
type="button" type="button"
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:opacity-95 focus:outline-none font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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]" transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
onClick={() => setIsAudiobookModalOpen(true)} onClick={() => setIsAudiobookModalOpen(true)}
> >
Export Audiobook Export Audiobook
@ -248,7 +247,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
> >
<div className="relative z-10 space-y-2"> <div className="relative z-10 space-y-2">
<label className="block text-sm font-medium text-foreground">Mode</label> <label className="block text-sm font-medium text-foreground">Mode</label>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent"> <ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate">{selectedView.name}</span> <span className="block truncate">{selectedView.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" /> <ChevronUpDownIcon className="h-5 w-5 text-muted" />
@ -261,11 +260,11 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none"> <ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{viewTypes.map((view) => ( {viewTypeTextMapping.map((view) => (
<ListboxOption <ListboxOption
key={view.id} key={view.id}
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground' `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
}` }`
} }
value={view} value={view}
@ -365,8 +364,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<Button <Button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent z-1" transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent z-1"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}

View file

@ -1,5 +1,3 @@
"use client";
import { ReactNode } from "react"; import { ReactNode } from "react";
export function Header({ export function Header({

View file

@ -350,7 +350,7 @@ export function SettingsModal() {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent"> <ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate"> <span className="block truncate">
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'} {ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
</span> </span>
@ -369,8 +369,8 @@ export function SettingsModal() {
<ListboxOption <ListboxOption
key={provider.id} key={provider.id}
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${ `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
active ? 'bg-accent/10 text-accent' : 'text-foreground' active ? 'bg-offbase text-accent' : 'text-foreground'
}` }`
} }
value={provider} value={provider}
@ -405,7 +405,7 @@ export function SettingsModal() {
value={localApiKey} value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)} onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"} placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"}
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
</div> </div>
@ -425,7 +425,7 @@ export function SettingsModal() {
} }
}} }}
> >
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent"> <ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate"> <span className="block truncate">
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'} {ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
</span> </span>
@ -444,8 +444,8 @@ export function SettingsModal() {
<ListboxOption <ListboxOption
key={model.id} key={model.id}
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${ `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
active ? 'bg-accent/10 text-accent' : 'text-foreground' active ? 'bg-offbase text-accent' : 'text-foreground'
}` }`
} }
value={model} value={model}
@ -477,7 +477,7 @@ export function SettingsModal() {
setModelValue(e.target.value); setModelValue(e.target.value);
}} }}
placeholder="Enter custom model name" placeholder="Enter custom model name"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
)} )}
</div> </div>
@ -490,7 +490,7 @@ export function SettingsModal() {
value={localTTSInstructions} value={localTTSInstructions}
onChange={(e) => setLocalTTSInstructions(e.target.value)} onChange={(e) => setLocalTTSInstructions(e.target.value)}
placeholder="Enter instructions for the TTS model" placeholder="Enter instructions for the TTS model"
className="w-full h-24 rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-full h-24 rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
)} )}
@ -507,7 +507,7 @@ export function SettingsModal() {
value={localBaseUrl} value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)} onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable" placeholder="Using environment variable"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
</div> </div>
@ -517,7 +517,7 @@ export function SettingsModal() {
<Button <Button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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" transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={async () => { onClick={async () => {
@ -534,7 +534,7 @@ export function SettingsModal() {
<Button <Button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm 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 font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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" transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
disabled={!canSubmit} disabled={!canSubmit}
@ -559,7 +559,7 @@ export function SettingsModal() {
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-sm font-medium text-foreground">Theme</label> <label className="block text-sm font-medium text-foreground">Theme</label>
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}> <Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent"> <ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate">{selectedTheme.name}</span> <span className="block truncate">{selectedTheme.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" /> <ChevronUpDownIcon className="h-5 w-5 text-muted" />
@ -571,12 +571,12 @@ export function SettingsModal() {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions className="absolute mt-1 max-h-40 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none"> <ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{themes.map((theme) => ( {themes.map((theme) => (
<ListboxOption <ListboxOption
key={theme.id} key={theme.id}
className={({ active }) => className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground' `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
}` }`
} }
value={theme} value={theme}
@ -609,7 +609,7 @@ export function SettingsModal() {
onClick={handleLoad} onClick={handleLoad}
disabled={isSyncing || isLoading} disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50" disabled:opacity-50"
@ -620,7 +620,7 @@ export function SettingsModal() {
onClick={handleSync} onClick={handleSync}
disabled={isSyncing || isLoading} disabled={isSyncing || isLoading}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-background/90 focus:outline-none font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50" disabled:opacity-50"
@ -636,7 +636,7 @@ export function SettingsModal() {
<Button <Button
onClick={() => setShowClearLocalConfirm(true)} onClick={() => setShowClearLocalConfirm(true)}
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm 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 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 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]" transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
> >

View file

@ -1,5 +1,3 @@
"use client";
export function ZoomControl({ export function ZoomControl({
value, value,
onIncrease, onIncrease,

View file

@ -1,3 +1,5 @@
"use client";
import { useState, DragEvent } from 'react'; import { useState, DragEvent } from 'react';
import { Button, Transition } from '@headlessui/react'; import { Button, Transition } from '@headlessui/react';
import { DocumentListItem } from './DocumentListItem'; import { DocumentListItem } from './DocumentListItem';

View file

@ -33,7 +33,7 @@ interface EPUBContextType {
clearCurrDoc: () => void; clearCurrDoc: () => void;
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>; extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
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>; 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' }>; regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
bookRef: RefObject<Book | null>; bookRef: RefObject<Book | null>;
renditionRef: RefObject<Rendition | undefined>; renditionRef: RefObject<Rendition | undefined>;
tocRef: RefObject<NavItem[]>; tocRef: RefObject<NavItem[]>;
@ -527,7 +527,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
chapterIndex: number, chapterIndex: number,
bookId: string, bookId: string,
format: 'mp3' | 'm4b', format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal signal: AbortSignal
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => { ): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
try { try {

View file

@ -68,7 +68,7 @@ interface PDFContextType {
enableHighlight?: boolean enableHighlight?: boolean
) => void; ) => void;
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>; 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' }>; regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
isAudioCombining: boolean; isAudioCombining: boolean;
} }
@ -479,7 +479,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
chapterIndex: number, chapterIndex: number,
bookId: string, bookId: string,
format: 'mp3' | 'm4b', format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal signal: AbortSignal
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => { ): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
try { try {

View file

@ -41,8 +41,8 @@ import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
import { isKokoroModel } from '@/utils/voice'; import { isKokoroModel } from '@/utils/voice';
import type { import type {
TTSLocation, TTSLocation,
ContinuationMergeResult, TTSSmartMergeResult,
PageTurnEstimate, TTSPageTurnEstimate,
TTSPlaybackState, TTSPlaybackState,
TTSRequestPayload, TTSRequestPayload,
TTSRequestHeaders, TTSRequestHeaders,
@ -176,7 +176,7 @@ const stripContinuationPrefix = (text: string, prefix: string) => {
return { text, removed: false }; return { text, removed: false };
}; };
const extractContinuationSlice = (nextText: string): ContinuationMergeResult | null => { const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null => {
if (!nextText?.trim()) { if (!nextText?.trim()) {
return null; return null;
} }
@ -216,7 +216,7 @@ const extractContinuationSlice = (nextText: string): ContinuationMergeResult | n
}; };
}; };
const mergeContinuation = (text: string, nextText: string): ContinuationMergeResult | null => { const mergeContinuation = (text: string, nextText: string): TTSSmartMergeResult | null => {
if (!needsSentenceContinuation(text)) { if (!needsSentenceContinuation(text)) {
return null; return null;
} }
@ -329,7 +329,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// Track continuation slices for PDF/EPUB page transitions // Track continuation slices for PDF/EPUB page transitions
const continuationCarryRef = useRef<Map<string, string>>(new Map()); const continuationCarryRef = useRef<Map<string, string>>(new Map());
const epubContinuationRef = useRef<string | null>(null); const epubContinuationRef = useRef<string | null>(null);
const pageTurnEstimateRef = useRef<PageTurnEstimate | null>(null); const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** /**
@ -562,7 +562,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) { if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) {
const continuationNormalized = preprocessSentenceForAudio(continuationCarried); const continuationNormalized = preprocessSentenceForAudio(continuationCarried);
if (continuationNormalized) { if (continuationNormalized) {
let bestEstimate: PageTurnEstimate | null = null; let bestEstimate: TTSPageTurnEstimate | null = null;
newSentences.forEach((sentence, index) => { newSentences.forEach((sentence, index) => {
const normalizedSentence = preprocessSentenceForAudio(sentence); const normalizedSentence = preprocessSentenceForAudio(sentence);

View file

@ -1,18 +1,5 @@
export type TTSLocation = string | number; export type TTSLocation = string | number;
// Result of merging a continuation slice into the current text
export interface ContinuationMergeResult {
text: string;
carried: string;
}
// Estimate for when a visual page/section turn should occur during audio playback
export interface PageTurnEstimate {
location: TTSLocation;
sentenceIndex: number;
fraction: number;
}
// Standardized error codes for the TTS API // Standardized error codes for the TTS API
export type TTSErrorCode = export type TTSErrorCode =
| 'MISSING_PARAMETERS' | 'MISSING_PARAMETERS'
@ -55,9 +42,33 @@ export interface TTSPlaybackState {
currDocPages?: number; currDocPages?: number;
} }
// Options for retrying TTS requests on failure in withRetry
export interface TTSRetryOptions { export interface TTSRetryOptions {
maxRetries?: number; maxRetries?: number;
initialDelay?: number; initialDelay?: number;
maxDelay?: number; maxDelay?: number;
backoffFactor?: number; backoffFactor?: number;
}
// Result of merging a continuation slice into the current text
export interface TTSSmartMergeResult {
text: string;
carried: string;
}
// Estimate for when a visual page/section turn should occur during audio playback
export interface TTSPageTurnEstimate {
location: TTSLocation;
sentenceIndex: number;
fraction: number;
}
// Metadata for an audiobook chapter
export interface TTSAudiobookChapter {
index: number;
title: string;
duration?: number;
status: 'pending' | 'generating' | 'completed' | 'error';
bookId?: string;
format?: 'mp3' | 'm4b';
} }

View file

@ -318,6 +318,16 @@ test.describe('Audiobook export', () => {
const regeneratingLabel = page.getByText(/Regenerating/); const regeneratingLabel = page.getByText(/Regenerating/);
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 }); await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
// After regeneration completes in the UI, verify backend chapter state is fully updated
// before triggering a full download to avoid races with ffmpeg concat on Alpine.
const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId);
expect(backendStateAfterRegenerate.exists).toBe(true);
expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true);
expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore);
for (const ch of backendStateAfterRegenerate.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
// Chapter count should remain exactly the same after regeneration (no duplicates) // Chapter count should remain exactly the same after regeneration (no duplicates)
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 }); await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });