feat(audiobook): implement SequentialPlayer for audio segment playback
This commit is contained in:
@@ -39,10 +39,107 @@ const STEP_HINTS: Record<string, string> = {
|
|||||||
const SEGMENT_STATUS_LABELS: Record<string, string> = {
|
const SEGMENT_STATUS_LABELS: Record<string, string> = {
|
||||||
pending: '待生成',
|
pending: '待生成',
|
||||||
generating: '生成中',
|
generating: '生成中',
|
||||||
done: '完成',
|
|
||||||
error: '出错',
|
error: '出错',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SequentialPlayer({
|
||||||
|
segments,
|
||||||
|
projectId,
|
||||||
|
onPlayingChange,
|
||||||
|
}: {
|
||||||
|
segments: AudiobookSegment[]
|
||||||
|
projectId: number
|
||||||
|
onPlayingChange: (segmentId: number | null) => void
|
||||||
|
}) {
|
||||||
|
const [displayIndex, setDisplayIndex] = useState<number | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(new Audio())
|
||||||
|
const blobUrlsRef = useRef<Record<number, string>>({})
|
||||||
|
const currentIndexRef = useRef<number | null>(null)
|
||||||
|
const doneSegments = segments.filter(s => s.status === 'done')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
return () => {
|
||||||
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
|
Object.values(blobUrlsRef.current).forEach(url => URL.revokeObjectURL(url))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
audioRef.current.pause()
|
||||||
|
audioRef.current.src = ''
|
||||||
|
currentIndexRef.current = null
|
||||||
|
setDisplayIndex(null)
|
||||||
|
setIsLoading(false)
|
||||||
|
onPlayingChange(null)
|
||||||
|
}, [onPlayingChange])
|
||||||
|
|
||||||
|
const playSegment = useCallback(async (index: number) => {
|
||||||
|
if (index >= doneSegments.length) {
|
||||||
|
currentIndexRef.current = null
|
||||||
|
setDisplayIndex(null)
|
||||||
|
onPlayingChange(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const seg = doneSegments[index]
|
||||||
|
currentIndexRef.current = index
|
||||||
|
setDisplayIndex(index)
|
||||||
|
onPlayingChange(seg.id)
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!blobUrlsRef.current[seg.id]) {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
audiobookApi.getSegmentAudioUrl(projectId, seg.id),
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
)
|
||||||
|
blobUrlsRef.current[seg.id] = URL.createObjectURL(response.data)
|
||||||
|
}
|
||||||
|
const audio = audioRef.current
|
||||||
|
audio.src = blobUrlsRef.current[seg.id]
|
||||||
|
await audio.play()
|
||||||
|
} catch {
|
||||||
|
playSegment(index + 1)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [doneSegments, projectId, onPlayingChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
const handleEnded = () => {
|
||||||
|
if (currentIndexRef.current !== null) {
|
||||||
|
playSegment(currentIndexRef.current + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audio.addEventListener('ended', handleEnded)
|
||||||
|
return () => audio.removeEventListener('ended', handleEnded)
|
||||||
|
}, [playSegment])
|
||||||
|
|
||||||
|
if (doneSegments.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{displayIndex !== null ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={stop}>
|
||||||
|
<Square className="h-3 w-3 mr-1 fill-current" />停止
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isLoading ? '加载中...' : `第 ${displayIndex + 1} / ${doneSegments.length} 段`}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => playSegment(0)}>
|
||||||
|
<Play className="h-3 w-3 mr-1" />顺序播放全部({doneSegments.length} 段)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
|
||||||
const [baseUrl, setBaseUrl] = useState('')
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
@@ -142,7 +239,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
const [segments, setSegments] = useState<AudiobookSegment[]>([])
|
const [segments, setSegments] = useState<AudiobookSegment[]>([])
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [loadingAction, setLoadingAction] = useState(false)
|
const [loadingAction, setLoadingAction] = useState(false)
|
||||||
const [playingSegmentId, setPlayingSegmentId] = useState<number | null>(null)
|
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
|
||||||
const autoExpandedRef = useRef(false)
|
const autoExpandedRef = useRef(false)
|
||||||
|
|
||||||
const fetchDetail = useCallback(async () => {
|
const fetchDetail = useCallback(async () => {
|
||||||
@@ -324,28 +421,26 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
|
|
||||||
{segments.length > 0 && (
|
{segments.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
片段列表 ({segments.length} 条)
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
片段列表({segments.length} 条)
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto space-y-1 pr-1">
|
<SequentialPlayer
|
||||||
|
segments={segments}
|
||||||
|
projectId={project.id}
|
||||||
|
onPlayingChange={setSequentialPlayingId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
{segments.slice(0, 50).map(seg => (
|
{segments.slice(0, 50).map(seg => (
|
||||||
<div key={seg.id}>
|
<div
|
||||||
<div className="flex items-start gap-2 text-xs border rounded px-2 py-1.5">
|
key={seg.id}
|
||||||
|
className={`border rounded px-2 py-2 space-y-2 transition-colors ${sequentialPlayingId === seg.id ? 'border-primary/50 bg-primary/5' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 text-xs">
|
||||||
<Badge variant="outline" className="shrink-0 text-xs mt-0.5">{seg.character_name || '?'}</Badge>
|
<Badge variant="outline" className="shrink-0 text-xs mt-0.5">{seg.character_name || '?'}</Badge>
|
||||||
<span className="text-muted-foreground flex-1 min-w-0 break-words leading-relaxed">{seg.text}</span>
|
<span className="text-muted-foreground flex-1 min-w-0 break-words leading-relaxed">{seg.text}</span>
|
||||||
{seg.status === 'done' ? (
|
{seg.status !== 'done' && (
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-5 w-5 shrink-0 mt-0.5"
|
|
||||||
onClick={() => setPlayingSegmentId(playingSegmentId === seg.id ? null : seg.id)}
|
|
||||||
>
|
|
||||||
{playingSegmentId === seg.id
|
|
||||||
? <Square className="h-2.5 w-2.5 fill-current" />
|
|
||||||
: <Play className="h-2.5 w-2.5" />
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Badge
|
<Badge
|
||||||
variant={seg.status === 'error' ? 'destructive' : 'secondary'}
|
variant={seg.status === 'error' ? 'destructive' : 'secondary'}
|
||||||
className="shrink-0 text-xs mt-0.5"
|
className="shrink-0 text-xs mt-0.5"
|
||||||
@@ -354,13 +449,11 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{playingSegmentId === seg.id && (
|
{seg.status === 'done' && (
|
||||||
<div className="mt-1 ml-1">
|
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)}
|
audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)}
|
||||||
jobId={seg.id}
|
jobId={seg.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user