'use client'; import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { Transition, 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 { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; import { VoicesControlBase } from '@/components/player/VoicesControlBase'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { getAudiobookStatus, deleteAudiobookChapter, deleteAudiobook, downloadAudiobookChapter, downloadAudiobook } from '@/lib/client/api/audiobooks'; import type { AudiobookGenerationSettings } from '@/types/client'; interface AudiobookExportModalProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; documentType: 'epub' | 'pdf' | 'html'; 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, providerRef, providerType, ttsModel, ttsInstructions, 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 providerModelPolicy = useMemo( () => resolveTtsProviderModelPolicy({ providerRef, providerType, model: ttsModel }), [providerRef, providerType, ttsModel], ); const nativeSpeedSupported = providerModelPolicy.supportsNativeModelSpeed; const effectiveNativeSpeed = nativeSpeedSupported ? nativeSpeed : 1; const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0; const isLegacyAudiobookMissingSettings = hasExistingAudiobook && savedSettings === null; useEffect(() => { // For new audiobooks (no saved settings/chapters), keep generation defaults aligned // with the current playback controls so users don't need a route remount. if (!isOpen) return; if (savedSettings) return; if (hasExistingAudiobook) return; setNativeSpeed(voiceSpeed); setPostSpeed(audioPlayerSpeed); setAudiobookVoice(configVoice || availableVoices[0] || ''); }, [ isOpen, savedSettings, hasExistingAudiobook, voiceSpeed, audioPlayerSpeed, configVoice, availableVoices, ]); 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 { providerRef, providerType, ttsModel, voice: nextVoice, nativeSpeed: effectiveNativeSpeed, postSpeed, format, ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined, }; }, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions]); 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)} ariaLabel="Export audiobook" title="Export Audiobook" subtitle="Only leaving the document cancels generation." > {isLoadingExisting ? ( ) : ( <>
{!isGenerating && (
{/* Header */}

Generation settings

{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}
Format
{savedSettings.format.toUpperCase()}
Native speed
{resolveTtsProviderModelPolicy({ providerRef: savedSettings.providerRef, providerType: savedSettings.providerType, model: savedSettings.ttsModel, }).supportsNativeModelSpeed ? `${formatSpeed(savedSettings.nativeSpeed)}x` : 'Not supported'}
Post speed
{formatSpeed(savedSettings.postSpeed)}x

Reset the audiobook to change generation settings.

) : (
{/* Voice & Format row */}
{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()}
)}
{/* Speed controls */}
{!nativeSpeedSupported && (
Native model speed is not available for this model.
)} {nativeSpeedSupported && ( <>
{formatSpeed(nativeSpeed)}x
setNativeSpeed(parseFloat(e.target.value))} className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-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 [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-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 [&::-moz-range-thumb]:border-0" />
0.5x 3x
)}
{formatSpeed(postSpeed)}x
setPostSpeed(parseFloat(e.target.value))} className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-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 [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-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 [&::-moz-range-thumb]:border-0" />
0.5x 3x
)} {/* Action buttons */}
{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) => (
{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.

)}
)} {/* 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} /> ); } function AudiobookSettingsSkeleton() { return (
{Array.from({ length: 4 }).map((_, index) => (
))}
); }