openreader/src/components/AudiobookExportModal.tsx
Richard R d90e48aaf3 feat(html): add HTML/TXT/MD sentence and word highlighting, settings, and audiobook export support
Introduce sentence and word highlighting for HTML/TXT/MD documents, including new config options (`htmlHighlightEnabled`, `htmlWordHighlightEnabled`) and synced user preferences. Update the HTML viewer to support these highlights using custom spans, and add corresponding CSS styles. Extend document settings to allow toggling these features. Implement block-level locator sorting and display improvements. Add audiobook export support for HTML documents, including adapter and pipeline integration.

- Add `htmlHighlightEnabled` and `htmlWordHighlightEnabled` to config, user state, and Dexie storage
- Update HTML viewer to apply and manage highlights for sentences and words
- Add CSS for HTML highlight classes and scroll margin
- Extend document settings UI for HTML highlight toggles
- Support HTML audiobook export in modal and pipeline
- Improve block locator sorting and sidebar display for HTML
- Add HTML audiobook adapter and block parsing utilities
2026-05-17 11:14:02 -06:00

983 lines
52 KiB
TypeScript

'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<string>; // Returns bookId
onRegenerateChapter?: (
chapterIndex: number,
bookId: string,
settings: AudiobookGenerationSettings,
signal: AbortSignal
) => Promise<TTSAudiobookChapter>;
}
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<TTSAudiobookChapter[]>([]);
const [bookId, setBookId] = useState<string | null>(null);
const [isCombining, setIsCombining] = useState(false);
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
const [currentChapter, setCurrentChapter] = useState<string>('');
const [format, setFormat] = useState<TTSAudiobookFormat>('m4b');
const [audiobookVoice, setAudiobookVoice] = useState<string>(configVoice || '');
const [nativeSpeed, setNativeSpeed] = useState<number>(voiceSpeed);
const [postSpeed, setPostSpeed] = useState<number>(audioPlayerSpeed);
const [savedSettings, setSavedSettings] = useState<AudiobookGenerationSettings | null>(null);
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
const abortControllerRef = useRef<AbortController | 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 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 (
<>
<ProgressPopup
isOpen={isGenerating && !isOpen}
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={handleCancel}
cancelText="Cancel"
operationType="audiobook"
onClick={() => setIsOpen(true)}
currentChapter={currentChapter}
totalChapters={documentType === 'epub' ? undefined : undefined}
completedChapters={chapters.filter(c => c.status === 'completed').length}
/>
<ReaderSidebarShell
isOpen={isOpen}
onClose={() => setIsOpen(false)}
ariaLabel="Export audiobook"
title="Export Audiobook"
subtitle="Only leaving the document cancels generation."
>
{isLoadingExisting ? (
<AudiobookSettingsSkeleton />
) : (
<>
<div className="space-y-4">
{!isGenerating && (
<div className="w-full rounded-xl border border-offbase bg-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-base rounded-t-xl">
<h4 className="text-sm font-medium text-foreground tracking-tight">Generation settings</h4>
{settingsLocked && (
<span className="inline-flex items-center gap-1 rounded-md bg-offbase px-2 py-0.5 text-[11px] font-medium text-muted uppercase tracking-wider">
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor"><path fillRule="evenodd" d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z" clipRule="evenodd" /></svg>
Locked
</span>
)}
</div>
<div className="p-4">
{isLegacyAudiobookMissingSettings && (
<div className="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground">
<div className="font-medium">Saved generation settings not found</div>
<div className="mt-1 text-muted">
This audiobook was likely created before v1 metadata was introduced, so OpenReader can&apos;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).
</div>
</div>
)}
{settingsLocked && savedSettings ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-base p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Voice</div>
<div className="text-sm font-medium text-foreground truncate">{savedSettings.voice}</div>
</div>
<div className="rounded-lg bg-base p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Format</div>
<div className="text-sm font-medium text-foreground">{savedSettings.format.toUpperCase()}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-base p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Native speed</div>
<div className="text-sm font-medium text-foreground">
{resolveTtsProviderModelPolicy({
providerRef: savedSettings.providerRef,
providerType: savedSettings.providerType,
model: savedSettings.ttsModel,
}).supportsNativeModelSpeed
? `${formatSpeed(savedSettings.nativeSpeed)}x`
: 'Not supported'}
</div>
</div>
<div className="rounded-lg bg-base p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Post speed</div>
<div className="text-sm font-medium text-foreground">{formatSpeed(savedSettings.postSpeed)}x</div>
</div>
</div>
<p className="text-xs text-muted">
Reset the audiobook to change generation settings.
</p>
</div>
) : (
<div className="space-y-4">
{/* Voice & Format row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Voice</label>
<VoicesControlBase
availableVoices={availableVoices}
voice={audiobookVoice}
onChangeVoice={setAudiobookVoice}
providerType={providerType}
ttsModel={ttsModel}
dropdownDirection="down"
variant="field"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Format</label>
{chapters.length === 0 ? (
<Listbox
value={format}
onChange={(newFormat) => setFormat(newFormat)}
disabled={chapters.length > 0 || settingsLocked}
>
<div className="relative">
<ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full">
<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-4 w-4 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-lg bg-base 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>
) : (
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
)}
</div>
</div>
{/* Speed controls */}
<div className="rounded-lg bg-base p-3 space-y-3">
{!nativeSpeedSupported && (
<div className="rounded-md border border-offbase bg-background px-2 py-1.5 text-[11px] text-muted">
Native model speed is not available for this model.
</div>
)}
{nativeSpeedSupported && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Native model speed</label>
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(nativeSpeed)}x</span>
</div>
<input
type="range"
min="0.5"
max="3"
step="0.1"
value={nativeSpeed}
onChange={(e) => 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"
/>
<div className="flex justify-between text-[10px] text-muted">
<span>0.5x</span>
<span>3x</span>
</div>
</div>
<div className="border-t border-offbase" />
</>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Post-generation speed</label>
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(postSpeed)}x</span>
</div>
<input
type="range"
min="0.5"
max="3"
step="0.1"
value={postSpeed}
onChange={(e) => 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"
/>
<div className="flex justify-between text-[10px] text-muted">
<span>0.5x</span>
<span>3x</span>
</div>
</div>
</div>
</div>
)}
{/* Action buttons */}
<div className="mt-4 flex items-center gap-2">
{chapters.length === 0 && (
<Button
onClick={handleStartGeneration}
disabled={!canGenerate}
className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 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.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
Start Generation
</Button>
)}
{showResumeButton && (
<Button
onClick={handleStartGeneration}
disabled={!canGenerate}
className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 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.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
>
Resume
</Button>
)}
{showResetButton && (
<Button
onClick={() => setShowResetConfirm(true)}
disabled={isGenerating}
className="inline-flex justify-center rounded-lg border border-red-500 bg-transparent px-3 py-2 text-sm
font-medium text-red-500 hover:bg-red-500 hover:text-background 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.01]"
title="Delete all generated chapters/pages for this document"
>
Reset
</Button>
)}
</div>
</div>
</div>
)}
{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={() => setShowRegenerateHint(false)}
className="ml-3 p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
aria-label="Dismiss regenerate hint"
>
<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 && (
<>
<div
className={`w-full 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 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
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
portal
className="w-44 rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-[70] p-1 origin-top-right"
>
{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">
<p className="text-sm text-muted">
Audiobook settings are fixed after generation. Chapters will appear here as they are ready.
</p>
</div>
)}
</div>
</>
)}
</ReaderSidebarShell>
{/* Confirm delete chapter */}
<ConfirmDialog
isOpen={pendingDeleteChapter !== null}
onClose={() => 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 */}
<ConfirmDialog
isOpen={showResetConfirm}
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."
confirmText="Reset"
cancelText="Cancel"
isDangerous
/>
{/* Error dialog replacing alerts */}
<ConfirmDialog
isOpen={errorMessage !== null}
onClose={() => setErrorMessage(null)}
onConfirm={() => setErrorMessage(null)}
title="Operation Failed"
message={errorMessage || ''}
confirmText="Close"
cancelText=""
isDangerous={false}
/>
</>
);
}
function AudiobookSettingsSkeleton() {
return (
<div className="space-y-4 animate-pulse" aria-label="Loading audiobook settings" aria-busy="true">
<div className="w-full rounded-xl border border-offbase bg-background overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-base">
<div className="h-4 w-40 rounded bg-offbase" />
<div className="h-5 w-14 rounded bg-offbase" />
</div>
<div className="p-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<div className="h-3 w-16 rounded bg-offbase" />
<div className="h-9 w-full rounded-md bg-offbase" />
</div>
<div className="space-y-1.5">
<div className="h-3 w-16 rounded bg-offbase" />
<div className="h-9 w-full rounded-md bg-offbase" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<div className="h-3 w-24 rounded bg-offbase" />
<div className="h-2 w-full rounded bg-offbase" />
</div>
<div className="space-y-1.5">
<div className="h-3 w-20 rounded bg-offbase" />
<div className="h-2 w-full rounded bg-offbase" />
</div>
</div>
<div className="h-9 w-full rounded-md bg-offbase" />
</div>
</div>
<div className="w-full rounded-xl border border-offbase bg-background overflow-hidden">
<div className="px-4 py-3 border-b border-offbase bg-base">
<div className="h-4 w-28 rounded bg-offbase" />
</div>
<div className="p-4 space-y-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-16 rounded-lg border border-offbase bg-base" />
))}
</div>
</div>
</div>
);
}