feat: Implement character voice preview playback and regeneration, and add a turbo mode status indicator for audiobook projects.
This commit is contained in:
@@ -139,6 +139,14 @@ export const audiobookApi = {
|
||||
return `/audiobook/projects/${projectId}/segments/${segmentId}/audio`
|
||||
},
|
||||
|
||||
getCharacterAudioUrl: (projectId: number, charId: number): string => {
|
||||
return `/audiobook/projects/${projectId}/characters/${charId}/audio`
|
||||
},
|
||||
|
||||
regenerateCharacterPreview: async (projectId: number, charId: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/characters/${charId}/regenerate-preview`)
|
||||
},
|
||||
|
||||
parseAllChapters: async (projectId: number, onlyErrors?: boolean): Promise<void> => {
|
||||
const params = onlyErrors ? '?only_errors=true' : ''
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/parse-all${params}`)
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"processing": "Processing",
|
||||
"generating": "Generating",
|
||||
"done": "Done",
|
||||
"error": "Error"
|
||||
"error": "Error",
|
||||
"turboActive": "⚡ Turbo"
|
||||
},
|
||||
|
||||
"stepHints": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"processing": "处理中",
|
||||
"generating": "生成中",
|
||||
"done": "已完成",
|
||||
"error": "出错"
|
||||
"error": "出错",
|
||||
"turboActive": "⚡ 极速并发"
|
||||
},
|
||||
|
||||
"stepHints": {
|
||||
@@ -83,7 +84,10 @@
|
||||
"descPlaceholder": "角色描述",
|
||||
"voiceDesign": "音色 #{{id}}",
|
||||
"noVoice": "未分配",
|
||||
"savedSuccess": "角色已保存"
|
||||
"savedSuccess": "角色已保存",
|
||||
"regeneratingPreview": "重新生成试听中...",
|
||||
"regeneratePreview": "重生试听",
|
||||
"previewNotReady": "试听收集中..."
|
||||
},
|
||||
|
||||
"confirm": {
|
||||
|
||||
@@ -343,6 +343,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
const [charsCollapsed, setCharsCollapsed] = useState(false)
|
||||
const [chaptersCollapsed, setChaptersCollapsed] = useState(false)
|
||||
const [expandedChapters, setExpandedChapters] = useState<Set<number>>(new Set())
|
||||
const [voiceKeys, setVoiceKeys] = useState<Record<number, number>>({})
|
||||
const [regeneratingVoices, setRegeneratingVoices] = useState<Set<number>>(new Set())
|
||||
const prevStatusRef = useRef(project.status)
|
||||
const autoExpandedRef = useRef(new Set<string>())
|
||||
|
||||
@@ -433,7 +435,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
setLoadingAction(true)
|
||||
setIsPolling(true)
|
||||
try {
|
||||
await audiobookApi.analyze(project.id, {})
|
||||
await audiobookApi.analyze(project.id, { turbo: true })
|
||||
toast.success(t('projectCard.analyzeStarted'))
|
||||
onRefresh()
|
||||
} catch (e: any) {
|
||||
@@ -525,6 +527,24 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegeneratePreview = async (charId: number) => {
|
||||
if (!project) return
|
||||
setRegeneratingVoices(prev => new Set(prev).add(charId))
|
||||
try {
|
||||
await audiobookApi.regenerateCharacterPreview(project.id, charId)
|
||||
toast.success(t('projectCard.characters.savedSuccess')) // or add a new toast key
|
||||
setVoiceKeys(prev => ({ ...prev, [charId]: (prev[charId] || 0) + 1 }))
|
||||
} catch (e: any) {
|
||||
toast.error(formatApiError(e))
|
||||
} finally {
|
||||
setRegeneratingVoices(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(charId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateAll = async () => {
|
||||
if (!detail) return
|
||||
setLoadingAction(true)
|
||||
@@ -667,6 +687,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
return status
|
||||
})()
|
||||
|
||||
const isTurboMode = ['analyzing', 'parsing', 'processing'].includes(displayStatus)
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -675,6 +697,11 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
<span className="font-medium break-words">{project.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isTurboMode && (
|
||||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 shadow-sm border-amber-500/20">
|
||||
{t('status.turboActive')}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={(STATUS_COLORS[displayStatus] || 'secondary') as any}>
|
||||
{t(`status.${displayStatus}`, { defaultValue: displayStatus })}
|
||||
</Badge>
|
||||
@@ -863,6 +890,34 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!editingCharId && char.voice_design_id && (
|
||||
<div className="mt-2 pl-1 pr-2 flex items-center justify-between gap-3 bg-muted/30 rounded-md p-1.5 border border-muted/50">
|
||||
<div className="flex-1 max-w-[200px] sm:max-w-[300px]">
|
||||
<LazyAudioPlayer
|
||||
key={`audio-${char.id}-${voiceKeys[char.id] || 0}`}
|
||||
audioUrl={`${audiobookApi.getCharacterAudioUrl(project.id, char.id)}?t=${voiceKeys[char.id] || 0}`}
|
||||
jobId={char.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'characters_ready' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => handleRegeneratePreview(char.id)}
|
||||
disabled={regeneratingVoices.has(char.id)}
|
||||
>
|
||||
{regeneratingVoices.has(char.id) ? (
|
||||
<><Loader2 className="h-3 w-3 mr-1 animate-spin" />{t('projectCard.characters.regeneratingPreview')}</>
|
||||
) : (
|
||||
<><RefreshCw className="h-3 w-3 mr-1" />{t('projectCard.characters.regeneratePreview')}</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
Reference in New Issue
Block a user