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 [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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user