feat: Add audio version tracking and update logic in ChaptersPanel component
This commit is contained in:
@@ -671,6 +671,23 @@ function ChaptersPanel({
|
||||
const [editEmoAlpha, setEditEmoAlpha] = useState(0.5)
|
||||
const [savingSegId, setSavingSegId] = useState<number | null>(null)
|
||||
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) => {
|
||||
setEditingSegId(seg.id)
|
||||
@@ -752,7 +769,7 @@ function ChaptersPanel({
|
||||
{!hasChapters ? (
|
||||
<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 => {
|
||||
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
|
||||
const chDone = chSegs.filter(s => s.status === 'done').length
|
||||
@@ -768,181 +785,175 @@ function ChaptersPanel({
|
||||
return next
|
||||
})
|
||||
return (
|
||||
<div key={ch.id} className="border rounded px-3 py-2 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs font-medium break-words flex-1">{chTitle}</span>
|
||||
{chSegs.length > 0 && (
|
||||
<button onClick={toggleChExpand} className="text-muted-foreground hover:text-foreground shrink-0 mt-0.5">
|
||||
{chExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{ch.status === 'pending' && (
|
||||
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
{t('projectCard.chapters.parse')}
|
||||
</Button>
|
||||
)}
|
||||
{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')}
|
||||
<div key={ch.id}>
|
||||
{/* Chapter header — flat, full-width, click to expand */}
|
||||
<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"
|
||||
onClick={toggleChExpand}
|
||||
>
|
||||
<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)' }} />}
|
||||
</span>
|
||||
<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' && (
|
||||
<Button size="sm" variant="outline" className="h-5 text-[11px] px-2" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
{t('projectCard.chapters.parse')}
|
||||
</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')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
|
||||
<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>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{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 && (
|
||||
<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 => {
|
||||
const isEditing = editingSegId === seg.id
|
||||
const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating'
|
||||
const isSaving = savingSegId === seg.id
|
||||
const audioV = audioVersions[seg.id] ?? 0
|
||||
return (
|
||||
<div key={seg.id} className={`py-2 space-y-1.5 ${sequentialPlayingId === seg.id ? 'bg-primary/5 px-1 rounded' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
<div
|
||||
key={seg.id}
|
||||
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')}
|
||||
</Badge>
|
||||
{!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 && seg.status === 'error' && <Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>}
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{!isEditing && (
|
||||
{!isRegenerating && seg.status === 'error' && (
|
||||
<Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-0.5 shrink-0">
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => startEdit(seg)}
|
||||
disabled={isRegenerating}
|
||||
title={t('projectCard.segments.edit')}
|
||||
>
|
||||
<Button 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" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => handleRegenerate(seg.id)}
|
||||
disabled={isRegenerating}
|
||||
title={t('projectCard.segments.regenerate')}
|
||||
>
|
||||
<Button 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" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isEditing && (
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 text-destructive"
|
||||
onClick={cancelEdit}
|
||||
title={t('projectCard.segments.cancel')}
|
||||
>
|
||||
<Button size="icon" variant="ghost" className="h-5 w-5 text-destructive" onClick={cancelEdit} title={t('projectCard.segments.cancel')}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 text-primary"
|
||||
onClick={() => saveEdit(seg.id)}
|
||||
disabled={isSaving}
|
||||
title={t('projectCard.segments.save')}
|
||||
>
|
||||
<Button 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" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={e => setEditText(e.target.value)}
|
||||
className="text-xs min-h-[60px] resize-y"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span>
|
||||
<select
|
||||
value={editEmoText}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
setEditEmoText(v)
|
||||
if (v && EMOTION_ALPHA_DEFAULTS[v]) setEditEmoAlpha(EMOTION_ALPHA_DEFAULTS[v])
|
||||
}}
|
||||
className="text-xs h-6 rounded border border-input bg-background px-1 focus:outline-none"
|
||||
>
|
||||
<option value="">{t('projectCard.segments.noEmotion')}</option>
|
||||
{EMOTION_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
{editEmoText && (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[120px]">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.intensity')}:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.05}
|
||||
max={0.9}
|
||||
step={0.05}
|
||||
value={editEmoAlpha}
|
||||
onChange={e => setEditEmoAlpha(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-8 text-right">{editEmoAlpha.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card body */}
|
||||
<div className="px-3 py-2.5">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={e => setEditText(e.target.value)}
|
||||
className="text-xs min-h-[60px] resize-y"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span>
|
||||
<select
|
||||
value={editEmoText}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
setEditEmoText(v)
|
||||
if (v && EMOTION_ALPHA_DEFAULTS[v]) setEditEmoAlpha(EMOTION_ALPHA_DEFAULTS[v])
|
||||
}}
|
||||
className="text-xs h-6 rounded border border-input bg-background px-1 focus:outline-none"
|
||||
>
|
||||
<option value="">{t('projectCard.segments.noEmotion')}</option>
|
||||
{EMOTION_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
{editEmoText && (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[120px]">
|
||||
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.intensity')}:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.05}
|
||||
max={0.9}
|
||||
step={0.05}
|
||||
value={editEmoAlpha}
|
||||
onChange={e => setEditEmoAlpha(Number(e.target.value))}
|
||||
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>
|
||||
) : (
|
||||
<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' && (
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user