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:
parent
04def62ff5
commit
ff150eec01
16 changed files with 384 additions and 347 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 "Start Generation" 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 "Start Generation" 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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Header({
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"use client";
|
||||
|
||||
export function ZoomControl({
|
||||
value,
|
||||
onIncrease,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useState, DragEvent } from 'react';
|
||||
import { Button, Transition } from '@headlessui/react';
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue