feat: Add audio version tracking and update logic in ChaptersPanel component

This commit is contained in:
2026-03-12 15:55:09 +08:00
parent bb6ad9b0a3
commit e15e654211

View File

@@ -671,6 +671,23 @@ function ChaptersPanel({
const [editEmoAlpha, setEditEmoAlpha] = useState(0.5) const [editEmoAlpha, setEditEmoAlpha] = useState(0.5)
const [savingSegId, setSavingSegId] = useState<number | null>(null) const [savingSegId, setSavingSegId] = useState<number | null>(null)
const [regeneratingSegs, setRegeneratingSegs] = useState<Set<number>>(new Set()) const [regeneratingSegs, setRegeneratingSegs] = useState<Set<number>>(new Set())
const [audioVersions, setAudioVersions] = useState<Record<number, number>>({})
const prevSegStatusRef = useRef<Record<number, string>>({})
useEffect(() => {
const bumps: Record<number, number> = {}
segments.forEach(seg => {
if (prevSegStatusRef.current[seg.id] === 'generating' && seg.status === 'done') {
bumps[seg.id] = (audioVersions[seg.id] ?? 0) + 1
}
})
if (Object.keys(bumps).length > 0) {
setAudioVersions(prev => ({ ...prev, ...bumps }))
}
const next: Record<number, string> = {}
segments.forEach(seg => { next[seg.id] = seg.status })
prevSegStatusRef.current = next
}, [segments])
const startEdit = (seg: AudiobookSegment) => { const startEdit = (seg: AudiobookSegment) => {
setEditingSegId(seg.id) setEditingSegId(seg.id)
@@ -752,7 +769,7 @@ function ChaptersPanel({
{!hasChapters ? ( {!hasChapters ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" /> <div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" />
) : ( ) : (
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-2"> <div className="flex-1 overflow-y-auto">
{detail!.chapters.map(ch => { {detail!.chapters.map(ch => {
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index) const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
const chDone = chSegs.filter(s => s.status === 'done').length const chDone = chSegs.filter(s => s.status === 'done').length
@@ -768,137 +785,122 @@ function ChaptersPanel({
return next return next
}) })
return ( return (
<div key={ch.id} className="border rounded px-3 py-2 space-y-2"> <div key={ch.id}>
<div className="flex items-start justify-between gap-2"> {/* Chapter header — flat, full-width, click to expand */}
<span className="text-xs font-medium break-words flex-1">{chTitle}</span> <button
{chSegs.length > 0 && ( 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"
<button onClick={toggleChExpand} className="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"> onClick={toggleChExpand}
{chExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />} >
</button> <span className="shrink-0 text-muted-foreground">
)} {chExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" style={{ transform: 'rotate(180deg)' }} />}
</div> </span>
<div className="flex items-center gap-1 flex-wrap"> <span className="text-xs font-medium flex-1 truncate">{chTitle}</span>
<span className="shrink-0 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
{ch.status === 'pending' && ( {ch.status === 'pending' && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => onParseChapter(ch.id, ch.title)}> <Button size="sm" variant="outline" className="h-5 text-[11px] px-2" onClick={() => onParseChapter(ch.id, ch.title)}>
{t('projectCard.chapters.parse')} {t('projectCard.chapters.parse')}
</Button> </Button>
)} )}
{ch.status === 'parsing' && ( {ch.status === 'parsing' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.parsing')}
<span>{t('projectCard.chapters.parsing')}</span> </span>
</div>
)} )}
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && ( {ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
<> <>
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={() => { <Button size="sm" variant="outline" className="h-5 text-[11px] px-2" disabled={loadingAction} onClick={() => {
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n }) setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
onGenerate(ch.chapter_index) onGenerate(ch.chapter_index)
}}> }}>
{t('projectCard.chapters.generate')} {t('projectCard.chapters.generate')}
</Button> </Button>
<Button size="sm" variant="ghost" className="h-6 text-xs px-2 text-muted-foreground" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}> <Button size="sm" variant="ghost" className="h-5 text-[11px] px-1.5 text-muted-foreground" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}>
{t('projectCard.chapters.reparse')} {t('projectCard.chapters.reparse')}
</Button> </Button>
</> </>
)} )}
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && ( {ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}
<span>{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}</span> </span>
</div>
)} )}
{ch.status === 'ready' && chAllDone && ( {ch.status === 'ready' && chAllDone && (
<> <>
<Badge variant="outline" className="text-xs"> <span className="text-[11px] text-muted-foreground">{t('projectCard.chapters.doneBadge', { count: chDone })}</span>
{t('projectCard.chapters.doneBadge', { count: chDone })} <Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
</Badge>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
<Download className="h-3 w-3" /> <Download className="h-3 w-3" />
</Button> </Button>
</> </>
)} )}
{ch.status === 'error' && ( {ch.status === 'error' && (
<> <Button size="sm" variant="outline" className="h-5 text-[11px] px-2 text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
{t('projectCard.chapters.reparse')} {t('projectCard.chapters.reparse')}
</Button> </Button>
{ch.error_message && (
<span className="text-xs text-destructive/80 truncate max-w-[200px]" title={ch.error_message}>{ch.error_message}</span>
)} )}
</> </span>
)} </button>
</div>
{ch.status === 'parsing' && ( {ch.status === 'parsing' && (
<div className="px-3 py-1 border-b">
<LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} /> <LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} />
</div>
)} )}
{/* Segments — flat list, each is its own card */}
{chExpanded && chSegs.length > 0 && ( {chExpanded && chSegs.length > 0 && (
<div className="pt-2 border-t divide-y divide-border/50"> <div className="px-3 py-2 space-y-2 border-b">
{chSegs.map(seg => { {chSegs.map(seg => {
const isEditing = editingSegId === seg.id const isEditing = editingSegId === seg.id
const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating' const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating'
const isSaving = savingSegId === seg.id const isSaving = savingSegId === seg.id
const audioV = audioVersions[seg.id] ?? 0
return ( return (
<div key={seg.id} className={`py-2 space-y-1.5 ${sequentialPlayingId === seg.id ? 'bg-primary/5 px-1 rounded' : ''}`}> <div
<div className="flex items-center gap-2"> key={seg.id}
<Badge variant="outline" className="text-xs shrink-0"> className={`rounded-lg border overflow-hidden ${sequentialPlayingId === seg.id ? 'border-primary/40 bg-primary/5' : 'bg-card'}`}
>
{/* Card header */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50">
<Badge variant="outline" className="text-xs shrink-0 font-normal">
{seg.character_name || t('projectCard.segments.unknownCharacter')} {seg.character_name || t('projectCard.segments.unknownCharacter')}
</Badge> </Badge>
{!isEditing && seg.emo_text && ( {!isEditing && seg.emo_text && (
<Badge variant="secondary" className="text-xs shrink-0">{seg.emo_text}</Badge> <span className="text-[11px] text-muted-foreground shrink-0">
{seg.emo_text}
{seg.emo_alpha != null && (
<span className="opacity-60 ml-0.5">{seg.emo_alpha.toFixed(2)}</span>
)}
</span>
)} )}
{isRegenerating && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />} {isRegenerating && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
{!isRegenerating && seg.status === 'error' && <Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>} {!isRegenerating && seg.status === 'error' && (
<div className="ml-auto flex items-center gap-1 shrink-0"> <Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>
{!isEditing && ( )}
<div className="ml-auto flex items-center gap-0.5 shrink-0">
{!isEditing ? (
<> <>
<Button <Button size="icon" variant="ghost" className="h-5 w-5" onClick={() => startEdit(seg)} disabled={isRegenerating} title={t('projectCard.segments.edit')}>
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => startEdit(seg)}
disabled={isRegenerating}
title={t('projectCard.segments.edit')}
>
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</Button> </Button>
<Button <Button size="icon" variant="ghost" className="h-5 w-5" onClick={() => handleRegenerate(seg.id)} disabled={isRegenerating} title={t('projectCard.segments.regenerate')}>
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => handleRegenerate(seg.id)}
disabled={isRegenerating}
title={t('projectCard.segments.regenerate')}
>
<RefreshCw className="h-3 w-3" /> <RefreshCw className="h-3 w-3" />
</Button> </Button>
</> </>
)} ) : (
{isEditing && (
<> <>
<Button <Button size="icon" variant="ghost" className="h-5 w-5 text-destructive" onClick={cancelEdit} title={t('projectCard.segments.cancel')}>
size="icon"
variant="ghost"
className="h-5 w-5 text-destructive"
onClick={cancelEdit}
title={t('projectCard.segments.cancel')}
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
<Button <Button size="icon" variant="ghost" className="h-5 w-5 text-primary" onClick={() => saveEdit(seg.id)} disabled={isSaving} title={t('projectCard.segments.save')}>
size="icon"
variant="ghost"
className="h-5 w-5 text-primary"
onClick={() => saveEdit(seg.id)}
disabled={isSaving}
title={t('projectCard.segments.save')}
>
{isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />} {isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</Button> </Button>
</> </>
)} )}
</div> </div>
</div> </div>
{/* Card body */}
<div className="px-3 py-2.5">
{isEditing ? ( {isEditing ? (
<div className="space-y-2"> <div className="space-y-2">
<Textarea <Textarea
@@ -939,10 +941,19 @@ function ChaptersPanel({
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground break-words leading-relaxed">{seg.text}</p> <p className="text-xs text-foreground/80 break-words leading-relaxed">{seg.text}</p>
)} )}
</div>
{/* Audio — integrated at bottom of card */}
{!isEditing && seg.status === 'done' && ( {!isEditing && seg.status === 'done' && (
<LazyAudioPlayer audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)} jobId={seg.id} /> <div className="px-3 pb-2">
<LazyAudioPlayer
key={`seg-audio-${seg.id}-${audioV}`}
audioUrl={`${audiobookApi.getSegmentAudioUrl(project.id, seg.id)}?v=${audioV}`}
jobId={seg.id}
/>
</div>
)} )}
</div> </div>
) )