'use client'; import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressCard } from '@/components/ProgressCard'; import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { LoadingSpinner } from '@/components/Spinner'; import { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; import { VoicesControlBase } from '@/components/player/VoicesControlBase'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { getAudiobookStatus, deleteAudiobookChapter, deleteAudiobook, downloadAudiobookChapter, downloadAudiobook } from '@/lib/client'; import type { AudiobookGenerationSettings } from '@/types/client'; interface AudiobookExportModalProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; documentType: 'epub' | 'pdf'; documentId: string; onGenerateAudiobook: ( onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, settings: AudiobookGenerationSettings ) => Promise; // Returns bookId onRegenerateChapter?: ( chapterIndex: number, bookId: string, settings: AudiobookGenerationSettings, signal: AbortSignal ) => Promise; } export function AudiobookExportModal({ isOpen, setIsOpen, documentType, documentId, onGenerateAudiobook, onRegenerateChapter }: AudiobookExportModalProps) { const { isLoading, isDBReady, ttsProvider, ttsModel, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig(); const { availableVoices } = useTTS(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const [isGenerating, setIsGenerating] = useState(false); const [chapters, setChapters] = useState([]); const [bookId, setBookId] = useState(null); const [isCombining, setIsCombining] = useState(false); const [isLoadingExisting, setIsLoadingExisting] = useState(false); const [isRefreshingChapters, setIsRefreshingChapters] = useState(false); const [currentChapter, setCurrentChapter] = useState(''); const [format, setFormat] = useState('m4b'); const [audiobookVoice, setAudiobookVoice] = useState(configVoice || ''); const [nativeSpeed, setNativeSpeed] = useState(voiceSpeed); const [postSpeed, setPostSpeed] = useState(audioPlayerSpeed); const [savedSettings, setSavedSettings] = useState(null); const [regeneratingChapter, setRegeneratingChapter] = useState(null); const abortControllerRef = useRef(null); const [pendingDeleteChapter, setPendingDeleteChapter] = useState(null); const [showResetConfirm, setShowResetConfirm] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [showRegenerateHint, setShowRegenerateHint] = useState(false); const formatSpeed = useCallback((speed: number) => { return Number.isInteger(speed) ? speed.toString() : speed.toFixed(1); }, []); const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0; const isLegacyAudiobookMissingSettings = hasExistingAudiobook && savedSettings === null; useEffect(() => { if (savedSettings) return; if (audiobookVoice) return; if (availableVoices.length > 0) { setAudiobookVoice(availableVoices[0] || ''); } }, [savedSettings, audiobookVoice, availableVoices]); const effectiveSettings: AudiobookGenerationSettings | null = useMemo(() => { if (savedSettings) return savedSettings; const nextVoice = audiobookVoice || configVoice || availableVoices[0] || ''; if (!nextVoice) return null; return { ttsProvider, ttsModel, voice: nextVoice, nativeSpeed, postSpeed, format, }; }, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, nativeSpeed, postSpeed, format]); const fetchExistingChapters = useCallback(async (soft: boolean = false) => { if (soft) { setIsRefreshingChapters(true); } else { setIsLoadingExisting(true); } try { const data = await getAudiobookStatus(documentId); if (data.exists) { setChapters(data.chapters || []); setBookId(data.bookId); if (data.chapters[0]?.format) { const detectedFormat = data.chapters[0].format as TTSAudiobookFormat; setFormat(detectedFormat); } if (data.settings) { setSavedSettings(data.settings); setAudiobookVoice(data.settings.voice); setNativeSpeed(data.settings.nativeSpeed); setPostSpeed(data.settings.postSpeed); setFormat(data.settings.format); } else { setSavedSettings(null); } if (data.hasComplete) { setProgress(100); } } else { // If nothing exists, clear chapters/bookId to reflect current state setChapters([]); setBookId(null); setSavedSettings(null); } } catch (error) { console.error('Error fetching existing chapters:', error); } finally { if (soft) { setIsRefreshingChapters(false); } else { setIsLoadingExisting(false); } } }, [documentId, setProgress]); // Fetch existing chapters when modal opens useEffect(() => { if (isOpen && documentId && !isGenerating) { fetchExistingChapters(); } }, [isOpen, documentId, isGenerating, fetchExistingChapters]); const handleChapterComplete = useCallback((chapter: TTSAudiobookChapter) => { setChapters(prev => { const existing = prev.find(c => c.index === chapter.index); if (existing) { return prev.map(c => c.index === chapter.index ? chapter : c); } return [...prev, chapter].sort((a, b) => a.index - b.index); }); setCurrentChapter(chapter.title); }, []); const handleStartGeneration = useCallback(async () => { if (!effectiveSettings) { setErrorMessage('No voice selected; please choose a voice before generating.'); return; } setIsGenerating(true); setProgress(0); setCurrentChapter(''); // Don't clear chapters if resuming if (!bookId) { setChapters([]); setBookId(null); } abortControllerRef.current = new AbortController(); try { const generatedBookId = await onGenerateAudiobook( (progress) => setProgress(progress), abortControllerRef.current.signal, handleChapterComplete, effectiveSettings ); setBookId(generatedBookId); } catch (error) { console.error('Error generating audiobook:', error); if (error instanceof Error && error.message.includes('cancelled')) { // Graceful cancellation - chapters are saved console.log('Audiobook generation cancelled gracefully'); } else { // Show error to user for actual errors setErrorMessage(error instanceof Error ? error.message : 'Failed to generate audiobook. Please try again.'); } } finally { setIsGenerating(false); setProgress(0); abortControllerRef.current = null; // Refresh chapters to show what was completed (soft refresh list only) if (bookId || documentId) { await fetchExistingChapters(true); } } }, [onGenerateAudiobook, handleChapterComplete, setProgress, bookId, documentId, fetchExistingChapters, effectiveSettings]); const handleCancel = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } }, []); // Cancel in-flight conversion if the page is being hidden or the component unmounts // (e.g., user navigates away from the document to the home screen). useEffect(() => { const onPageHide = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; window.addEventListener('pagehide', onPageHide); window.addEventListener('beforeunload', onPageHide); return () => { window.removeEventListener('pagehide', onPageHide); window.removeEventListener('beforeunload', onPageHide); if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; }, []); const handleRegenerateChapter = useCallback(async (chapter: TTSAudiobookChapter) => { if (!onRegenerateChapter || !bookId) return; if (!effectiveSettings) { setErrorMessage('No voice selected; please choose a voice before generating.'); return; } if (!showRegenerateHint) { setShowRegenerateHint(true); } setRegeneratingChapter(chapter.index); setCurrentChapter(`Regenerating: ${chapter.title}`); abortControllerRef.current = new AbortController(); try { // Update chapter status to generating setChapters(prev => { const exists = prev.some(c => c.index === chapter.index); if (exists) { return prev.map(c => c.index === chapter.index ? { ...c, status: 'generating' as const } : c ); } // If it's a missing placeholder, add it as generating return [...prev, { ...chapter, status: 'generating' as const }].sort((a, b) => a.index - b.index); }); const regeneratedChapter = await onRegenerateChapter( chapter.index, bookId, effectiveSettings, abortControllerRef.current.signal ); // Update chapter with new data setChapters(prev => prev.map(c => c.index === chapter.index ? regeneratedChapter : c )); } catch (error) { console.error('Error regenerating chapter:', error); if (error instanceof Error && error.message.includes('cancelled')) { console.log('Chapter regeneration cancelled'); } else { setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate chapter. Please try again.'); // Mark as error setChapters(prev => prev.map(c => c.index === chapter.index ? { ...c, status: 'error' as const } : c )); } } finally { setRegeneratingChapter(null); setCurrentChapter(''); setProgress(0); abortControllerRef.current = null; // Refresh chapters to get updated data (soft refresh list only) await fetchExistingChapters(true); } }, [onRegenerateChapter, bookId, setProgress, fetchExistingChapters, showRegenerateHint, effectiveSettings]); const performDeleteChapter = useCallback(async () => { if (!bookId || !pendingDeleteChapter) return; try { await deleteAudiobookChapter(bookId, pendingDeleteChapter.index); setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index)); await fetchExistingChapters(true); } catch (error) { console.error('Error deleting chapter:', error); setErrorMessage('Failed to delete chapter. Please try again.'); } finally { setPendingDeleteChapter(null); } }, [bookId, pendingDeleteChapter, fetchExistingChapters]); const performResetAll = useCallback(async () => { const targetBookId = bookId || documentId; if (!targetBookId) return; try { await deleteAudiobook(targetBookId); setChapters([]); setBookId(null); setProgress(0); } catch (error) { console.error('Error resetting audiobook chapters:', error); setErrorMessage('Failed to reset chapters. Please try again.'); } finally { setShowResetConfirm(false); await fetchExistingChapters(true); } }, [bookId, documentId, setProgress, fetchExistingChapters]); const handleDownloadChapter = useCallback(async (chapter: TTSAudiobookChapter) => { if (!chapter.bookId) return; try { const blob = await downloadAudiobookChapter(chapter.bookId, chapter.index); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Use the chapter's stored format directly - it knows what it actually is const ext = chapter.format || 'm4b'; a.download = `${chapter.title}.${ext}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Error downloading chapter:', error); setErrorMessage('Failed to download chapter. Please try again.'); } }, []); const handleDownloadComplete = useCallback(async () => { if (!bookId) return; setIsCombining(true); try { const response = await downloadAudiobook(bookId, format); const reader = response.body?.getReader(); if (!reader) throw new Error('No response body'); const chunks: Uint8Array[] = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; const blob = new Blob(chunks as BlobPart[], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `audiobook.${format}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Error downloading complete audiobook:', error); setErrorMessage('Failed to download audiobook. Please try again.'); } finally { setIsCombining(false); } }, [bookId, format]); const formatDuration = (seconds?: number) => { if (!seconds) return '--:--'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; // Compute display list including gaps before the highest existing index const maxIndex = chapters.length > 0 ? Math.max(...chapters.map(c => c.index)) : -1; const displayChapters: 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 }; }) : []; // 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; const settingsLocked = savedSettings !== null; const canGenerate = effectiveSettings !== null; // Do not render until storage/config is initialized if (isLoading || !isDBReady) { return null; } return ( <> setIsOpen(true)} currentChapter={currentChapter} totalChapters={documentType === 'epub' ? undefined : undefined} completedChapters={chapters.filter(c => c.status === 'completed').length} /> setIsOpen(false)}>
{isLoadingExisting ? (
) : ( <>

Export Audiobook

{!isGenerating && (

Generation settings

These settings are saved per audiobook for consistent resumes across devices.

{settingsLocked && ( Locked )}
{isLegacyAudiobookMissingSettings && (
Saved generation settings not found
This audiobook was likely created before v1 metadata was introduced, so OpenReader can’t know which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with v1 metadata (so settings are saved for resumes across devices).
)} {settingsLocked && savedSettings ? (
Voice: {savedSettings.voice}
Native speed: {formatSpeed(savedSettings.nativeSpeed)}x
Post speed: {formatSpeed(savedSettings.postSpeed)}x
Format: {savedSettings.format.toUpperCase()}

Reset the audiobook to change generation settings.

) : (
Voice
Output format
{chapters.length === 0 ? ( setFormat(newFormat)} disabled={chapters.length > 0 || settingsLocked} >
{format.toUpperCase()} `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' }` } > {({ selected }) => ( M4B )} `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' }` } > {({ selected }) => ( MP3 )}
) : (
{format.toUpperCase()}
)}
Native model speed
• {formatSpeed(nativeSpeed)}x
setNativeSpeed(parseFloat(e.target.value))} className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent" />
Post-generation speed
• {formatSpeed(postSpeed)}x
setPostSpeed(parseFloat(e.target.value))} className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent" />
)}
{chapters.length === 0 && ( )} {showResumeButton && ( )} {showResetButton && ( )}
)} {showRegenerateHint && (

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

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

Chapters

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

{chapter.title}

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

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

Audiobook settings are fixed after generation. Chapters will appear here as they are ready.

You can close this dialog while the audiobook is being generated. But returning to the home screen will cancel the generation.

)}
)}
{/* Confirm delete chapter */} setPendingDeleteChapter(null)} onConfirm={performDeleteChapter} title="Delete Chapter" message={pendingDeleteChapter ? `Delete "${pendingDeleteChapter.title}"? This will remove the audio and metadata for this chapter.` : ''} confirmText="Delete" cancelText="Cancel" isDangerous /> {/* Confirm reset all */} setShowResetConfirm(false)} onConfirm={performResetAll} title="Reset Audiobook" message="Reset audiobook? This deletes all generated chapters/pages and any combined files. This cannot be undone." confirmText="Reset" cancelText="Cancel" isDangerous /> {/* Error dialog replacing alerts */} setErrorMessage(null)} onConfirm={() => setErrorMessage(null)} title="Operation Failed" message={errorMessage || ''} confirmText="Close" cancelText="" isDangerous={false} /> ); }