From f20b250430cc0df7cefe6a7b04df97c095a3b09f Mon Sep 17 00:00:00 2001 From: bdim404 Date: Mon, 9 Mar 2026 12:00:03 +0800 Subject: [PATCH] feat(audiobook): implement SequentialPlayer for audio segment playback --- qwen3-tts-frontend/src/pages/Audiobook.tsx | 147 +++++++++++++++++---- 1 file changed, 120 insertions(+), 27 deletions(-) diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index 092d665..18bdad4 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -39,10 +39,107 @@ const STEP_HINTS: Record = { const SEGMENT_STATUS_LABELS: Record = { pending: '待生成', generating: '生成中', - done: '完成', error: '出错', } +function SequentialPlayer({ + segments, + projectId, + onPlayingChange, +}: { + segments: AudiobookSegment[] + projectId: number + onPlayingChange: (segmentId: number | null) => void +}) { + const [displayIndex, setDisplayIndex] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const audioRef = useRef(new Audio()) + const blobUrlsRef = useRef>({}) + const currentIndexRef = useRef(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 ( +
+ {displayIndex !== null ? ( + <> + + + {isLoading ? '加载中...' : `第 ${displayIndex + 1} / ${doneSegments.length} 段`} + + + ) : ( + + )} +
+ ) +} + function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) { const [baseUrl, setBaseUrl] = useState('') const [apiKey, setApiKey] = useState('') @@ -142,7 +239,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr const [segments, setSegments] = useState([]) const [expanded, setExpanded] = useState(false) const [loadingAction, setLoadingAction] = useState(false) - const [playingSegmentId, setPlayingSegmentId] = useState(null) + const [sequentialPlayingId, setSequentialPlayingId] = useState(null) const autoExpandedRef = useRef(false) const fetchDetail = useCallback(async () => { @@ -324,28 +421,26 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr {segments.length > 0 && (
-
- 片段列表 ({segments.length} 条) +
+
+ 片段列表({segments.length} 条) +
+
-
+
{segments.slice(0, 50).map(seg => ( -
-
+
+
{seg.character_name || '?'} {seg.text} - {seg.status === 'done' ? ( - - ) : ( + {seg.status !== 'done' && ( )}
- {playingSegmentId === seg.id && ( -
- -
+ {seg.status === 'done' && ( + )}
))}