feat: Refactor AudioPlayer and Audiobook components to improve loading state handling and integrate dialog components
This commit is contained in:
@@ -20,12 +20,17 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
|||||||
const [blobUrl, setBlobUrl] = useState<string>('')
|
const [blobUrl, setBlobUrl] = useState<string>('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
const [retryCount, setRetryCount] = useState(0)
|
||||||
const previousAudioUrlRef = useRef<string>('')
|
const previousAudioUrlRef = useRef<string>('')
|
||||||
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const playerInstanceRef = useRef<WaveformPlayer | null>(null)
|
const playerInstanceRef = useRef<WaveformPlayer | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioUrl || audioUrl === previousAudioUrlRef.current) return
|
if (!audioUrl) return
|
||||||
|
const cacheKey = `${audioUrl}::${retryCount}`
|
||||||
|
if (cacheKey === previousAudioUrlRef.current) return
|
||||||
|
|
||||||
let active = true
|
let active = true
|
||||||
const prevBlobUrl = blobUrl
|
const prevBlobUrl = blobUrl
|
||||||
@@ -33,6 +38,7 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
|||||||
const fetchAudio = async () => {
|
const fetchAudio = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
|
setIsPending(false)
|
||||||
|
|
||||||
if (prevBlobUrl) {
|
if (prevBlobUrl) {
|
||||||
URL.revokeObjectURL(prevBlobUrl)
|
URL.revokeObjectURL(prevBlobUrl)
|
||||||
@@ -43,13 +49,21 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
|||||||
if (active) {
|
if (active) {
|
||||||
const url = URL.createObjectURL(response.data)
|
const url = URL.createObjectURL(response.data)
|
||||||
setBlobUrl(url)
|
setBlobUrl(url)
|
||||||
previousAudioUrlRef.current = audioUrl
|
previousAudioUrlRef.current = cacheKey
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error("Failed to load audio:", error)
|
|
||||||
if (active) {
|
if (active) {
|
||||||
|
const status = (error as { response?: { status?: number } })?.response?.status
|
||||||
|
if (status === 404) {
|
||||||
|
setIsPending(true)
|
||||||
|
retryTimerRef.current = setTimeout(() => {
|
||||||
|
if (active) setRetryCount(c => c + 1)
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
console.error("Failed to load audio:", error)
|
||||||
setLoadError(t('failedToLoadAudio'))
|
setLoadError(t('failedToLoadAudio'))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (active) {
|
if (active) {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -61,8 +75,12 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
|
if (retryTimerRef.current) {
|
||||||
|
clearTimeout(retryTimerRef.current)
|
||||||
|
retryTimerRef.current = null
|
||||||
}
|
}
|
||||||
}, [audioUrl, blobUrl, t])
|
}
|
||||||
|
}, [audioUrl, retryCount, blobUrl, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -119,7 +137,7 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
|||||||
link.click()
|
link.click()
|
||||||
}, [blobUrl, audioUrl, jobId])
|
}, [blobUrl, audioUrl, jobId])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading || isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-4 border rounded-lg">
|
<div className="flex items-center justify-center p-4 border rounded-lg">
|
||||||
<span className="text-sm text-muted-foreground">{t('loadingAudio')}</span>
|
<span className="text-sm text-muted-foreground">{t('loadingAudio')}</span>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Navbar } from '@/components/Navbar'
|
import { Navbar } from '@/components/Navbar'
|
||||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment } from '@/lib/api/audiobook'
|
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment } from '@/lib/api/audiobook'
|
||||||
@@ -213,7 +214,7 @@ function LogStream({ projectId, chapterId, active }: { projectId: number; chapte
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
function LLMConfigDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const { t } = useTranslation('audiobook')
|
const { t } = useTranslation('audiobook')
|
||||||
const [baseUrl, setBaseUrl] = useState('')
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
@@ -222,8 +223,8 @@ function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
|||||||
const [existing, setExisting] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null)
|
const [existing, setExisting] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
audiobookApi.getLLMConfig().then(setExisting).catch(() => {})
|
if (open) audiobookApi.getLLMConfig().then(setExisting).catch(() => {})
|
||||||
}, [])
|
}, [open])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!baseUrl || !apiKey || !model) {
|
if (!baseUrl || !apiKey || !model) {
|
||||||
@@ -237,7 +238,7 @@ function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
|||||||
setApiKey('')
|
setApiKey('')
|
||||||
const updated = await audiobookApi.getLLMConfig()
|
const updated = await audiobookApi.getLLMConfig()
|
||||||
setExisting(updated)
|
setExisting(updated)
|
||||||
onSaved?.()
|
onClose()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(formatApiError(e))
|
toast.error(formatApiError(e))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -246,8 +247,12 @@ function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg p-4 space-y-3">
|
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||||
<div className="font-medium text-sm">{t('llmConfigPanel.title')}</div>
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('llmConfigPanel.title')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
{existing && (
|
{existing && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{t('llmConfigPanel.current', {
|
{t('llmConfigPanel.current', {
|
||||||
@@ -260,14 +265,21 @@ function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
|||||||
<Input placeholder="Base URL (e.g. https://api.openai.com/v1)" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
|
<Input placeholder="Base URL (e.g. https://api.openai.com/v1)" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
|
||||||
<Input placeholder="API Key" type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
<Input placeholder="API Key" type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} />
|
||||||
<Input placeholder="Model (e.g. gpt-4o)" value={model} onChange={e => setModel(e.target.value)} />
|
<Input placeholder="Model (e.g. gpt-4o)" value={model} onChange={e => setModel(e.target.value)} />
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={onClose} disabled={loading}>
|
||||||
|
{t('cancel', { defaultValue: '取消' })}
|
||||||
|
</Button>
|
||||||
<Button size="sm" onClick={handleSave} disabled={loading}>
|
<Button size="sm" onClick={handleSave} disabled={loading}>
|
||||||
{loading ? t('llmConfigPanel.saving') : t('llmConfigPanel.save')}
|
{loading ? t('llmConfigPanel.saving') : t('llmConfigPanel.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
function CreateProjectDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
|
||||||
const { t } = useTranslation('audiobook')
|
const { t } = useTranslation('audiobook')
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [sourceType, setSourceType] = useState<'text' | 'epub'>('text')
|
const [sourceType, setSourceType] = useState<'text' | 'epub'>('text')
|
||||||
@@ -275,6 +287,8 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
|||||||
const [epubFile, setEpubFile] = useState<File | null>(null)
|
const [epubFile, setEpubFile] = useState<File | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const reset = () => { setTitle(''); setText(''); setEpubFile(null); setSourceType('text') }
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!title) { toast.error(t('createPanel.titleRequired')); return }
|
if (!title) { toast.error(t('createPanel.titleRequired')); return }
|
||||||
if (sourceType === 'text' && !text) { toast.error(t('createPanel.textRequired')); return }
|
if (sourceType === 'text' && !text) { toast.error(t('createPanel.textRequired')); return }
|
||||||
@@ -287,8 +301,9 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
|||||||
await audiobookApi.uploadEpub(title, epubFile!)
|
await audiobookApi.uploadEpub(title, epubFile!)
|
||||||
}
|
}
|
||||||
toast.success(t('createPanel.createdSuccess'))
|
toast.success(t('createPanel.createdSuccess'))
|
||||||
setTitle(''); setText(''); setEpubFile(null)
|
reset()
|
||||||
onCreated()
|
onCreated()
|
||||||
|
onClose()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(formatApiError(e))
|
toast.error(formatApiError(e))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -297,8 +312,12 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg p-4 space-y-3">
|
<Dialog open={open} onOpenChange={v => { if (!v) { reset(); onClose() } }}>
|
||||||
<div className="font-medium text-sm">{t('createPanel.title')}</div>
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('createPanel.title')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
<Input placeholder={t('createPanel.titlePlaceholder')} value={title} onChange={e => setTitle(e.target.value)} />
|
<Input placeholder={t('createPanel.titlePlaceholder')} value={title} onChange={e => setTitle(e.target.value)} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant={sourceType === 'text' ? 'default' : 'outline'} onClick={() => setSourceType('text')}>
|
<Button size="sm" variant={sourceType === 'text' ? 'default' : 'outline'} onClick={() => setSourceType('text')}>
|
||||||
@@ -315,15 +334,20 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
|
|||||||
<Input type="file" accept=".epub" onChange={e => {
|
<Input type="file" accept=".epub" onChange={e => {
|
||||||
const file = e.target.files?.[0] || null
|
const file = e.target.files?.[0] || null
|
||||||
setEpubFile(file)
|
setEpubFile(file)
|
||||||
if (file && !title) {
|
if (file && !title) setTitle(file.name.replace(/\.epub$/i, ''))
|
||||||
setTitle(file.name.replace(/\.epub$/i, ''))
|
|
||||||
}
|
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { reset(); onClose() }} disabled={loading}>
|
||||||
|
{t('cancel', { defaultValue: '取消' })}
|
||||||
|
</Button>
|
||||||
<Button size="sm" onClick={handleCreate} disabled={loading}>
|
<Button size="sm" onClick={handleCreate} disabled={loading}>
|
||||||
{loading ? t('createPanel.creating') : t('createPanel.create')}
|
{loading ? t('createPanel.creating') : t('createPanel.create')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +372,7 @@ function ProjectListSidebar({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('audiobook')
|
const { t } = useTranslation('audiobook')
|
||||||
return (
|
return (
|
||||||
<div className={`${collapsed ? 'w-10' : 'w-60'} shrink-0 flex flex-col bg-muted/30 overflow-hidden transition-all duration-200`}>
|
<div className={`${collapsed ? 'w-10' : 'w-64'} shrink-0 flex flex-col bg-muted/30 overflow-hidden transition-all duration-200`}>
|
||||||
<div className="h-16 flex items-center shrink-0 px-2 gap-1">
|
<div className="h-16 flex items-center shrink-0 px-2 gap-1">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0 ml-1">
|
<div className="flex items-center gap-2 flex-1 min-w-0 ml-1">
|
||||||
@@ -370,7 +394,7 @@ function ProjectListSidebar({
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-1.5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-3 py-4 text-xs text-muted-foreground">{t('loading')}</div>
|
<div className="px-3 py-4 text-xs text-muted-foreground">{t('loading')}</div>
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
@@ -380,10 +404,17 @@ function ProjectListSidebar({
|
|||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => onSelect(p.id)}
|
onClick={() => onSelect(p.id)}
|
||||||
className={`w-full text-left px-3 py-2 flex flex-col gap-0.5 hover:bg-muted/50 transition-colors border-b border-border/40 ${selectedId === p.id ? 'bg-muted' : ''}`}
|
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors hover:bg-muted/60 ${
|
||||||
|
selectedId === p.id
|
||||||
|
? 'bg-background border-border shadow-sm'
|
||||||
|
: 'bg-background/50 border-border/40'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium truncate">{p.title}</span>
|
<div className="flex items-start gap-2">
|
||||||
<Badge variant={(STATUS_COLORS[p.status] || 'secondary') as any} className="text-[10px] h-4 px-1 self-start">
|
<Book className="h-3.5 w-3.5 shrink-0 mt-0.5 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium leading-snug break-words min-w-0 flex-1">{p.title}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={(STATUS_COLORS[p.status] || 'secondary') as any} className="text-[10px] h-4 px-1 self-start ml-5">
|
||||||
{t(`status.${p.status}`, { defaultValue: p.status })}
|
{t(`status.${p.status}`, { defaultValue: p.status })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</button>
|
</button>
|
||||||
@@ -416,6 +447,9 @@ function CharactersPanel({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onAnalyze,
|
onAnalyze,
|
||||||
onFetchDetail,
|
onFetchDetail,
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
onScrollToChapter,
|
||||||
}: {
|
}: {
|
||||||
project: AudiobookProject
|
project: AudiobookProject
|
||||||
detail: AudiobookProjectDetail | null
|
detail: AudiobookProjectDetail | null
|
||||||
@@ -423,6 +457,9 @@ function CharactersPanel({
|
|||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
onAnalyze: () => void
|
onAnalyze: () => void
|
||||||
onFetchDetail: () => Promise<void>
|
onFetchDetail: () => Promise<void>
|
||||||
|
collapsed: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onScrollToChapter: (chapterId: number) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('audiobook')
|
const { t } = useTranslation('audiobook')
|
||||||
const [editingCharId, setEditingCharId] = useState<number | null>(null)
|
const [editingCharId, setEditingCharId] = useState<number | null>(null)
|
||||||
@@ -479,6 +516,38 @@ function CharactersPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const charCount = detail?.characters.length ?? 0
|
const charCount = detail?.characters.length ?? 0
|
||||||
|
const hasChaptersOutline = (detail?.chapters.length ?? 0) > 0
|
||||||
|
|
||||||
|
if (collapsed && hasChaptersOutline) {
|
||||||
|
return (
|
||||||
|
<div className="w-44 shrink-0 flex flex-col border-r border-emerald-500/20 bg-emerald-500/5 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-2 py-2 shrink-0 border-b border-emerald-500/20">
|
||||||
|
<span className="text-xs font-medium text-emerald-400/80 truncate">
|
||||||
|
{t('projectCard.chapters.title', { count: detail!.chapters.length })}
|
||||||
|
</span>
|
||||||
|
<Button size="icon" variant="ghost" className="h-6 w-6 shrink-0 ml-1" onClick={onToggle}>
|
||||||
|
<PanelLeftOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{detail!.chapters.map(ch => {
|
||||||
|
const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 })
|
||||||
|
const dotClass = ch.status === 'done' ? 'bg-green-400' : ch.status === 'error' ? 'bg-destructive' : ch.status === 'parsing' || ch.status === 'generating' ? 'bg-amber-400' : ch.status === 'ready' ? 'bg-blue-400' : 'bg-muted-foreground/50'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ch.id}
|
||||||
|
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/60 border-b border-border/30 transition-colors"
|
||||||
|
onClick={() => onScrollToChapter(ch.id)}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotClass}`} />
|
||||||
|
<span className="text-xs truncate">{chTitle}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 shrink-0 flex flex-col border-r border-blue-500/20 bg-blue-500/5 overflow-hidden">
|
<div className="w-72 shrink-0 flex flex-col border-r border-blue-500/20 bg-blue-500/5 overflow-hidden">
|
||||||
@@ -486,11 +555,18 @@ function CharactersPanel({
|
|||||||
<span className="text-xs font-medium text-blue-400/80">
|
<span className="text-xs font-medium text-blue-400/80">
|
||||||
{t('projectCard.characters.title', { count: charCount })}
|
{t('projectCard.characters.title', { count: charCount })}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{!isActive && status !== 'pending' && charCount > 0 && (
|
{!isActive && status !== 'pending' && charCount > 0 && (
|
||||||
<Button size="sm" variant="ghost" className="h-6 text-xs px-2 text-muted-foreground" onClick={onAnalyze} disabled={loadingAction}>
|
<Button size="sm" variant="ghost" className="h-6 text-xs px-2 text-muted-foreground" onClick={onAnalyze} disabled={loadingAction}>
|
||||||
{t('projectCard.reanalyze')}
|
{t('projectCard.reanalyze')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{hasChaptersOutline && (
|
||||||
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={onToggle}>
|
||||||
|
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(!detail || charCount === 0) ? (
|
{(!detail || charCount === 0) ? (
|
||||||
@@ -646,6 +722,8 @@ function ChaptersPanel({
|
|||||||
onSequentialPlayingChange,
|
onSequentialPlayingChange,
|
||||||
onUpdateSegment,
|
onUpdateSegment,
|
||||||
onRegenerateSegment,
|
onRegenerateSegment,
|
||||||
|
scrollToChapterId,
|
||||||
|
onScrollToChapterDone,
|
||||||
}: {
|
}: {
|
||||||
project: AudiobookProject
|
project: AudiobookProject
|
||||||
detail: AudiobookProjectDetail | null
|
detail: AudiobookProjectDetail | null
|
||||||
@@ -662,6 +740,8 @@ function ChaptersPanel({
|
|||||||
onSequentialPlayingChange: (id: number | null) => void
|
onSequentialPlayingChange: (id: number | null) => void
|
||||||
onUpdateSegment: (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => Promise<void>
|
onUpdateSegment: (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => Promise<void>
|
||||||
onRegenerateSegment: (segmentId: number) => Promise<void>
|
onRegenerateSegment: (segmentId: number) => Promise<void>
|
||||||
|
scrollToChapterId: number | null
|
||||||
|
onScrollToChapterDone: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('audiobook')
|
const { t } = useTranslation('audiobook')
|
||||||
const [expandedChapters, setExpandedChapters] = useState<Set<number>>(new Set())
|
const [expandedChapters, setExpandedChapters] = useState<Set<number>>(new Set())
|
||||||
@@ -674,6 +754,15 @@ function ChaptersPanel({
|
|||||||
const [audioVersions, setAudioVersions] = useState<Record<number, number>>({})
|
const [audioVersions, setAudioVersions] = useState<Record<number, number>>({})
|
||||||
const prevSegStatusRef = useRef<Record<number, string>>({})
|
const prevSegStatusRef = useRef<Record<number, string>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollToChapterId) return
|
||||||
|
setExpandedChapters(prev => { const n = new Set(prev); n.add(scrollToChapterId); return n })
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById(`ch-${scrollToChapterId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}, 50)
|
||||||
|
onScrollToChapterDone()
|
||||||
|
}, [scrollToChapterId, onScrollToChapterDone])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bumps: Record<number, number> = {}
|
const bumps: Record<number, number> = {}
|
||||||
segments.forEach(seg => {
|
segments.forEach(seg => {
|
||||||
@@ -785,7 +874,7 @@ function ChaptersPanel({
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<div key={ch.id}>
|
<div key={ch.id} id={`ch-${ch.id}`}>
|
||||||
{/* Chapter header — flat, full-width, click to expand */}
|
{/* Chapter header — flat, full-width, click to expand */}
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2.5 bg-muted/40 hover:bg-muted/70 border-b text-left transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2.5 bg-muted/40 hover:bg-muted/70 border-b text-left transition-colors"
|
||||||
@@ -989,6 +1078,8 @@ export default function Audiobook() {
|
|||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [showLLM, setShowLLM] = useState(false)
|
const [showLLM, setShowLLM] = useState(false)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
const [charactersCollapsed, setCharactersCollapsed] = useState(false)
|
||||||
|
const [scrollToChapterId, setScrollToChapterId] = useState<number | null>(null)
|
||||||
const prevStatusRef = useRef<string>('')
|
const prevStatusRef = useRef<string>('')
|
||||||
const autoExpandedRef = useRef(new Set<string>())
|
const autoExpandedRef = useRef(new Set<string>())
|
||||||
|
|
||||||
@@ -1360,16 +1451,8 @@ export default function Audiobook() {
|
|||||||
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
|
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="flex-1 flex flex-col overflow-hidden bg-background rounded-tl-2xl">
|
<div className="flex-1 flex flex-col overflow-hidden bg-background rounded-tl-2xl">
|
||||||
{showLLM && (
|
<LLMConfigDialog open={showLLM} onClose={() => setShowLLM(false)} />
|
||||||
<div className="shrink-0 border-b px-4 py-3">
|
<CreateProjectDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={fetchProjects} />
|
||||||
<LLMConfigPanel onSaved={() => setShowLLM(false)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showCreate && (
|
|
||||||
<div className="shrink-0 border-b px-4 py-3">
|
|
||||||
<CreateProjectPanel onCreated={() => { setShowCreate(false); fetchProjects() }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!selectedProject ? (
|
{!selectedProject ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
@@ -1491,6 +1574,9 @@ export default function Audiobook() {
|
|||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onAnalyze={handleAnalyze}
|
onAnalyze={handleAnalyze}
|
||||||
onFetchDetail={fetchDetail}
|
onFetchDetail={fetchDetail}
|
||||||
|
collapsed={charactersCollapsed}
|
||||||
|
onToggle={() => setCharactersCollapsed(v => !v)}
|
||||||
|
onScrollToChapter={(id) => setScrollToChapterId(id)}
|
||||||
/>
|
/>
|
||||||
<ChaptersPanel
|
<ChaptersPanel
|
||||||
project={selectedProject}
|
project={selectedProject}
|
||||||
@@ -1508,6 +1594,8 @@ export default function Audiobook() {
|
|||||||
onSequentialPlayingChange={setSequentialPlayingId}
|
onSequentialPlayingChange={setSequentialPlayingId}
|
||||||
onUpdateSegment={handleUpdateSegment}
|
onUpdateSegment={handleUpdateSegment}
|
||||||
onRegenerateSegment={handleRegenerateSegment}
|
onRegenerateSegment={handleRegenerateSegment}
|
||||||
|
scrollToChapterId={scrollToChapterId}
|
||||||
|
onScrollToChapterDone={() => setScrollToChapterId(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user