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
RUN pnpm exec next telemetry disable
RUN pnpm run build
RUN pnpm build
# Expose the port the app runs on
EXPOSE 3003

View file

@ -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) {

View file

@ -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,

View file

@ -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) {

View file

@ -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<string>; // Returns bookId
onRegenerateChapter?: (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal
) => Promise<AudiobookChapter>;
) => Promise<TTSAudiobookChapter>;
}
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<AudiobookChapter[]>([]);
const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]);
const [bookId, setBookId] = useState<string | null>(null);
const [isCombining, setIsCombining] = useState(false);
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
@ -56,11 +48,11 @@ export function AudiobookExportModal({
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 [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<>
<ProgressPopup
<ProgressPopup
isOpen={isGenerating && !isOpen}
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
@ -395,7 +386,7 @@ export function AudiobookExportModal({
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
@ -428,264 +419,296 @@ export function AudiobookExportModal({
</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 && (
<>
<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-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="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-offbase 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-offbase 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]"
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]"
>
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
</Button>
)}
{showResetButton && (
<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]"
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]"
title="Delete all generated chapters/pages for this document"
>
Reset
</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>
{bookId && !isGenerating && (
<div className="pt-4 border-t border-offbase">
{showRegenerateHint && (
<div className="flex items-start justify-between bg-offbase border border-offbase rounded-md px-3 py-2 text-xs sm:text-sm">
<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
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]"
onClick={() => setShowRegenerateHint(false)}
className="ml-3 p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
aria-label="Dismiss regenerate hint"
>
<DownloadIcon className="h-5 w-5" />
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
<svg className="w-4 h-4" 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>
)}
</>
)}
{/* 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 && (
<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>
{chapters.length > 0 && (
<>
<div className={`space-y-2 max-h-96 ${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 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 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
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase 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>
</>
onClick={() => setIsOpen(false)}
>
Close
</Button>
</div>
</>
)}
</DialogPanel>
</TransitionChild>
@ -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

View file

@ -75,8 +75,8 @@ export function ConfirmDialog({
<div className="mt-6 flex justify-end space-x-3">
<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
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase 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={onClose}
@ -85,12 +85,12 @@ export function ConfirmDialog({
</button>
<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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
${isDangerous
? 'bg-accent text-background hover:bg-accent/90 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'
: 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
}`}
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 viewTypes = [
const viewTypeTextMapping = [
{ id: 'single', name: 'Single Page' },
{ id: 'dual', name: 'Two Pages' },
{ id: 'scroll', name: 'Continuous Scroll' },
@ -45,7 +45,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
right: rightMargin
});
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
useEffect(() => {
@ -91,13 +91,12 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
onProgress: (progress: number) => void,
signal: AbortSignal
) => {
if (epub) {
return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal);
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
} else {
return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal);
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
}
}, [epub, regenerateEPUBChapter, regeneratePDFChapter]);
@ -141,10 +140,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
{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
className="w-full 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]"
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
onClick={() => setIsAudiobookModalOpen(true)}
>
Export Audiobook
@ -248,7 +247,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
>
<div className="relative z-10 space-y-2">
<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="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
@ -261,11 +260,11 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
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">
{viewTypes.map((view) => (
{viewTypeTextMapping.map((view) => (
<ListboxOption
key={view.id}
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}
@ -365,8 +364,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
<div className="mt-3 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
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase 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 z-1"
onClick={() => setIsOpen(false)}

View file

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

View file

@ -350,7 +350,7 @@ export function SettingsModal() {
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">
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
</span>
@ -369,8 +369,8 @@ export function SettingsModal() {
<ListboxOption
key={provider.id}
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={provider}
@ -405,7 +405,7 @@ export function SettingsModal() {
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
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>
@ -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">
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
</span>
@ -444,8 +444,8 @@ export function SettingsModal() {
<ListboxOption
key={model.id}
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={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"
/>
)}
</div>
@ -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"
/>
</div>
)}
@ -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"
/>
</div>
</div>
@ -517,7 +517,7 @@ export function SettingsModal() {
<Button
type="button"
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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={async () => {
@ -534,7 +534,7 @@ export function SettingsModal() {
<Button
type="button"
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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
disabled={!canSubmit}
@ -559,7 +559,7 @@ export function SettingsModal() {
<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">
<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="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
@ -571,12 +571,12 @@ export function SettingsModal() {
leaveFrom="opacity-100"
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) => (
<ListboxOption
key={theme.id}
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}
@ -609,7 +609,7 @@ export function SettingsModal() {
onClick={handleLoad}
disabled={isSyncing || isLoading}
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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
@ -620,7 +620,7 @@ export function SettingsModal() {
onClick={handleSync}
disabled={isSyncing || isLoading}
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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
@ -636,7 +636,7 @@ export function SettingsModal() {
<Button
onClick={() => setShowClearLocalConfirm(true)}
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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>

View file

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

View file

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

View file

@ -33,7 +33,7 @@ interface EPUBContextType {
clearCurrDoc: () => void;
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>;
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>;
renditionRef: RefObject<Rendition | undefined>;
tocRef: RefObject<NavItem[]>;
@ -527,7 +527,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
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 {

View file

@ -68,7 +68,7 @@ interface PDFContextType {
enableHighlight?: boolean
) => 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>;
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;
}
@ -479,7 +479,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
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 {

View file

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

View file

@ -1,18 +1,5 @@
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
export type TTSErrorCode =
| 'MISSING_PARAMETERS'
@ -55,9 +42,33 @@ export interface TTSPlaybackState {
currDocPages?: number;
}
// Options for retrying TTS requests on failure in withRetry
export interface TTSRetryOptions {
maxRetries?: number;
initialDelay?: number;
maxDelay?: 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/);
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)
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });