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,181 +785,175 @@ 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>
{ch.status === 'pending' && ( <span className="shrink-0 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => onParseChapter(ch.id, ch.title)}> {ch.status === 'pending' && (
{t('projectCard.chapters.parse')} <Button size="sm" variant="outline" className="h-5 text-[11px] px-2" onClick={() => onParseChapter(ch.id, ch.title)}>
</Button> {t('projectCard.chapters.parse')}
)}
{ch.status === 'parsing' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{t('projectCard.chapters.parsing')}</span>
</div>
)}
{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={() => {
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
onGenerate(ch.chapter_index)
}}>
{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)}> )}
{ch.status === 'parsing' && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.parsing')}
</span>
)}
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
<>
<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 })
onGenerate(ch.chapter_index)
}}>
{t('projectCard.chapters.generate')}
</Button>
<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')}
</Button>
</>
)}
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}
</span>
)}
{ch.status === 'ready' && chAllDone && (
<>
<span className="text-[11px] text-muted-foreground">{t('projectCard.chapters.doneBadge', { count: chDone })}</span>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
<Download className="h-3 w-3" />
</Button>
</>
)}
{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)}>
{t('projectCard.chapters.reparse')} {t('projectCard.chapters.reparse')}
</Button> </Button>
</> )}
)} </span>
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && ( </button>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}</span>
</div>
)}
{ch.status === 'ready' && chAllDone && (
<>
<Badge variant="outline" className="text-xs">
{t('projectCard.chapters.doneBadge', { count: chDone })}
</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" />
</Button>
</>
)}
{ch.status === 'error' && (
<>
<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')}
</Button>
{ch.error_message && (
<span className="text-xs text-destructive/80 truncate max-w-[200px]" title={ch.error_message}>{ch.error_message}</span>
)}
</>
)}
</div>
{ch.status === 'parsing' && ( {ch.status === 'parsing' && (
<LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} /> <div className="px-3 py-1 border-b">
<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>
{isEditing ? (
<div className="space-y-2"> {/* Card body */}
<Textarea <div className="px-3 py-2.5">
value={editText} {isEditing ? (
onChange={e => setEditText(e.target.value)} <div className="space-y-2">
className="text-xs min-h-[60px] resize-y" <Textarea
rows={3} value={editText}
/> onChange={e => setEditText(e.target.value)}
<div className="flex items-center gap-2 flex-wrap"> className="text-xs min-h-[60px] resize-y"
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span> rows={3}
<select />
value={editEmoText} <div className="flex items-center gap-2 flex-wrap">
onChange={e => { <span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span>
const v = e.target.value <select
setEditEmoText(v) value={editEmoText}
if (v && EMOTION_ALPHA_DEFAULTS[v]) setEditEmoAlpha(EMOTION_ALPHA_DEFAULTS[v]) onChange={e => {
}} const v = e.target.value
className="text-xs h-6 rounded border border-input bg-background px-1 focus:outline-none" setEditEmoText(v)
> if (v && EMOTION_ALPHA_DEFAULTS[v]) setEditEmoAlpha(EMOTION_ALPHA_DEFAULTS[v])
<option value="">{t('projectCard.segments.noEmotion')}</option> }}
{EMOTION_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)} className="text-xs h-6 rounded border border-input bg-background px-1 focus:outline-none"
</select> >
{editEmoText && ( <option value="">{t('projectCard.segments.noEmotion')}</option>
<div className="flex items-center gap-1.5 flex-1 min-w-[120px]"> {EMOTION_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.intensity')}:</span> </select>
<input {editEmoText && (
type="range" <div className="flex items-center gap-1.5 flex-1 min-w-[120px]">
min={0.05} <span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.intensity')}:</span>
max={0.9} <input
step={0.05} type="range"
value={editEmoAlpha} min={0.05}
onChange={e => setEditEmoAlpha(Number(e.target.value))} max={0.9}
className="flex-1 h-1.5 accent-primary" step={0.05}
/> value={editEmoAlpha}
<span className="text-xs text-muted-foreground w-8 text-right">{editEmoAlpha.toFixed(2)}</span> onChange={e => setEditEmoAlpha(Number(e.target.value))}
</div> className="flex-1 h-1.5 accent-primary"
)} />
<span className="text-xs text-muted-foreground w-8 text-right">{editEmoAlpha.toFixed(2)}</span>
</div>
)}
</div>
</div> </div>
</div> ) : (
) : ( <p className="text-xs text-foreground/80 break-words leading-relaxed">{seg.text}</p>
<p className="text-xs text-muted-foreground 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>
) )