import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame, Headphones } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Navbar } from '@/components/Navbar' import { AudioPlayer } from '@/components/AudioPlayer' import { ChapterPlayer } from '@/components/ChapterPlayer' import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest, type NsfwScriptGenerationRequest } from '@/lib/api/audiobook' import { RotateCcw } from 'lucide-react' import apiClient, { formatApiError, adminApi, authApi } from '@/lib/api' import { useAuth } from '@/contexts/AuthContext' function LazyAudioPlayer({ audioUrl, jobId }: { audioUrl: string; jobId: number }) { const [visible, setVisible] = useState(false) const ref = useRef(null) useEffect(() => { const el = ref.current if (!el) return const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setVisible(true); observer.disconnect() } }, { rootMargin: '120px' } ) observer.observe(el) return () => observer.disconnect() }, []) return
{visible && }
} const STATUS_COLORS: Record = { pending: 'secondary', analyzing: 'default', characters_ready: 'default', parsing: 'default', processing: 'default', ready: 'default', generating: 'default', done: 'outline', error: 'destructive', } const STEP_HINT_STATUSES = ['pending', 'analyzing', 'characters_ready', 'ready', 'generating'] function SequentialPlayer({ segments, projectId, onPlayingChange, }: { segments: AudiobookSegment[] projectId: number onPlayingChange: (segmentId: number | null) => void }) { const { t } = useTranslation('audiobook') const [displayIndex, setDisplayIndex] = useState(null) const [isLoading, setIsLoading] = useState(false) const audioRef = useRef(new Audio()) const blobUrlsRef = useRef>({}) const currentIndexRef = useRef(null) const doneSegments = segments.filter(s => s.status === 'done') useEffect(() => { const audio = audioRef.current return () => { audio.pause() audio.src = '' Object.values(blobUrlsRef.current).forEach(url => URL.revokeObjectURL(url)) } }, []) const stop = useCallback(() => { audioRef.current.pause() audioRef.current.src = '' currentIndexRef.current = null setDisplayIndex(null) setIsLoading(false) onPlayingChange(null) }, [onPlayingChange]) const playSegment = useCallback(async (index: number) => { if (index >= doneSegments.length) { currentIndexRef.current = null setDisplayIndex(null) onPlayingChange(null) return } const seg = doneSegments[index] currentIndexRef.current = index setDisplayIndex(index) onPlayingChange(seg.id) setIsLoading(true) try { if (!blobUrlsRef.current[seg.id]) { const response = await apiClient.get( audiobookApi.getSegmentAudioUrl(projectId, seg.id), { responseType: 'blob' } ) blobUrlsRef.current[seg.id] = URL.createObjectURL(response.data) } const audio = audioRef.current audio.src = blobUrlsRef.current[seg.id] await audio.play() } catch { playSegment(index + 1) } finally { setIsLoading(false) } }, [doneSegments, projectId, onPlayingChange]) useEffect(() => { const audio = audioRef.current const handleEnded = () => { if (currentIndexRef.current !== null) { playSegment(currentIndexRef.current + 1) } } audio.addEventListener('ended', handleEnded) return () => audio.removeEventListener('ended', handleEnded) }, [playSegment]) if (doneSegments.length === 0) return null return (
{displayIndex !== null ? ( <> {isLoading ? t('projectCard.sequential.loading') : t('projectCard.sequential.progress', { current: displayIndex + 1, total: doneSegments.length })} ) : ( )}
) } function LogStream({ projectId, chapterId, active }: { projectId: number; chapterId?: number; active: boolean }) { const [lines, setLines] = useState([]) const [done, setDone] = useState(false) const containerRef = useRef(null) useEffect(() => { if (!active) return setLines([]) setDone(false) const token = localStorage.getItem('token') const apiBase = (import.meta.env.VITE_API_URL as string) || '' const controller = new AbortController() const chapterParam = chapterId !== undefined ? `?chapter_id=${chapterId}` : '' fetch(`${apiBase}/audiobook/projects/${projectId}/logs${chapterParam}`, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }).then(async res => { const reader = res.body?.getReader() if (!reader) return const decoder = new TextDecoder() let buffer = '' while (true) { const { done: streamDone, value } = await reader.read() if (streamDone) break buffer += decoder.decode(value, { stream: true }) const parts = buffer.split('\n\n') buffer = parts.pop() ?? '' for (const part of parts) { const line = part.trim() if (!line.startsWith('data: ')) continue try { const msg = JSON.parse(line.slice(6)) if (msg.done) { setDone(true) } else if (typeof msg.index === 'number') { setLines(prev => { const next = [...prev] next[msg.index] = msg.line return next }) } } catch {} } } }).catch(() => {}) return () => controller.abort() }, [projectId, chapterId, active]) useEffect(() => { const el = containerRef.current if (el) el.scrollTop = el.scrollHeight }, [lines]) if (lines.length === 0) return null return (
{lines.map((line, i) => (
{line}
))} {!done && ( )}
) } function LLMConfigDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const { t } = useTranslation('audiobook') const [baseUrl, setBaseUrl] = useState('') const [apiKey, setApiKey] = useState('') const [model, setModel] = useState('') const [loading, setLoading] = useState(false) const [existing, setExisting] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null) useEffect(() => { if (open) adminApi.getLLMConfig().then(setExisting).catch(() => {}) }, [open]) const handleSave = async () => { if (!baseUrl || !apiKey || !model) { toast.error(t('llmConfigPanel.incompleteError')) return } setLoading(true) try { await adminApi.setLLMConfig({ base_url: baseUrl, api_key: apiKey, model }) toast.success(t('llmConfigPanel.savedSuccess')) setApiKey('') const updated = await adminApi.getLLMConfig() setExisting(updated) onClose() } catch (e: any) { toast.error(formatApiError(e)) } finally { setLoading(false) } } return ( { if (!v) onClose() }}> {t('llmConfigPanel.title')}
{existing && (
{t('llmConfigPanel.current', { baseUrl: existing.base_url || t('llmConfigPanel.notSet'), model: existing.model || t('llmConfigPanel.notSet'), keyStatus: existing.has_key ? t('llmConfigPanel.hasKey') : t('llmConfigPanel.noKey'), })}
)} setBaseUrl(e.target.value)} /> setApiKey(e.target.value)} /> setModel(e.target.value)} />
) } function CreateProjectDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) { const { t } = useTranslation('audiobook') const [title, setTitle] = useState('') const [sourceType, setSourceType] = useState<'text' | 'epub'>('text') const [text, setText] = useState('') const [epubFile, setEpubFile] = useState(null) const [loading, setLoading] = useState(false) const reset = () => { setTitle(''); setText(''); setEpubFile(null); setSourceType('text') } const handleCreate = async () => { if (!title) { toast.error(t('createPanel.titleRequired')); return } if (sourceType === 'text' && !text) { toast.error(t('createPanel.textRequired')); return } if (sourceType === 'epub' && !epubFile) { toast.error(t('createPanel.epubRequired')); return } setLoading(true) try { if (sourceType === 'text') { await audiobookApi.createProject({ title, source_type: 'text', source_text: text }) } else { await audiobookApi.uploadEpub(title, epubFile!) } toast.success(t('createPanel.createdSuccess')) reset() onCreated() onClose() } catch (e: any) { toast.error(formatApiError(e)) } finally { setLoading(false) } } return ( { if (!v) { reset(); onClose() } }}> {t('createPanel.title')}
setTitle(e.target.value)} />
{sourceType === 'text' && (