From 3393be4967bfdd721e85de03de72e6752c60467d Mon Sep 17 00:00:00 2001 From: bdim404 Date: Fri, 13 Mar 2026 16:21:11 +0800 Subject: [PATCH] feat: add ChapterPlayer component for audio chapter playback in Audiobook --- .../src/components/ChapterPlayer.tsx | 203 ++++++++++++++++++ qwen3-tts-frontend/src/pages/Audiobook.tsx | 30 ++- 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 qwen3-tts-frontend/src/components/ChapterPlayer.tsx diff --git a/qwen3-tts-frontend/src/components/ChapterPlayer.tsx b/qwen3-tts-frontend/src/components/ChapterPlayer.tsx new file mode 100644 index 0000000..1d35066 --- /dev/null +++ b/qwen3-tts-frontend/src/components/ChapterPlayer.tsx @@ -0,0 +1,203 @@ +import { useRef, useState, useEffect, memo } from 'react' +import { useTheme } from '@/contexts/ThemeContext' +import WaveformPlayer from '@arraypress/waveform-player' +import '@arraypress/waveform-player/dist/waveform-player.css' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { X, Loader2 } from 'lucide-react' +import apiClient from '@/lib/api' +import { audiobookApi, type AudiobookSegment } from '@/lib/api/audiobook' +import styles from './AudioPlayer.module.css' + +interface TimelineItem { + seg: AudiobookSegment + startTime: number + endTime: number +} + +const ChapterPlayer = memo(({ + projectId, + chapterIndex, + chapterTitle, + segments, + onClose, +}: { + projectId: number + chapterIndex: number + chapterTitle: string + segments: AudiobookSegment[] + onClose: () => void +}) => { + const { theme } = useTheme() + const [blobUrl, setBlobUrl] = useState('') + const [isLoadingChapter, setIsLoadingChapter] = useState(true) + const [isLoadingTimeline, setIsLoadingTimeline] = useState(false) + const [currentSeg, setCurrentSeg] = useState(null) + + const containerRef = useRef(null) + const playerRef = useRef(null) + const timelineRef = useRef([]) + const blobUrlRef = useRef('') + + const doneSegs = segments.filter(s => s.status === 'done') + const doneSegIds = doneSegs.map(s => s.id).join(',') + + useEffect(() => { + let active = true + setIsLoadingChapter(true) + setCurrentSeg(null) + timelineRef.current = [] + + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current) + blobUrlRef.current = '' + setBlobUrl('') + } + + apiClient.get(audiobookApi.getDownloadUrl(projectId, chapterIndex), { responseType: 'blob' }) + .then(res => { + if (!active) return + const url = URL.createObjectURL(res.data) + blobUrlRef.current = url + setBlobUrl(url) + setIsLoadingChapter(false) + }) + .catch(() => { + if (active) setIsLoadingChapter(false) + }) + + return () => { active = false } + }, [projectId, chapterIndex]) + + useEffect(() => { + if (!doneSegIds) return + let active = true + setIsLoadingTimeline(true) + timelineRef.current = [] + + const build = async () => { + let cumTime = 0 + const result: TimelineItem[] = [] + for (const seg of doneSegs) { + if (!active) break + try { + const res = await apiClient.get( + audiobookApi.getSegmentAudioUrl(projectId, seg.id), + { responseType: 'blob' } + ) + const url = URL.createObjectURL(res.data) + const duration = await new Promise(resolve => { + const a = new Audio(url) + a.addEventListener('loadedmetadata', () => { URL.revokeObjectURL(url); resolve(a.duration || 0) }) + a.addEventListener('error', () => { URL.revokeObjectURL(url); resolve(0) }) + }) + result.push({ seg, startTime: cumTime, endTime: cumTime + duration }) + cumTime += duration + } catch { + result.push({ seg, startTime: cumTime, endTime: cumTime + 1 }) + cumTime += 1 + } + } + if (active) { + timelineRef.current = result + setIsLoadingTimeline(false) + } + } + + build() + return () => { active = false } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [doneSegIds, projectId]) + + useEffect(() => { + if (!containerRef.current || !blobUrl) return + + if (playerRef.current) { + playerRef.current.destroy() + playerRef.current = null + } + + const waveformColor = theme === 'dark' ? '#4b5563' : '#d1d5db' + const progressColor = theme === 'dark' ? '#a78bfa' : '#7c3aed' + + const player = new WaveformPlayer(containerRef.current, { + url: blobUrl, + waveformStyle: 'mirror', + height: 60, + barWidth: 3, + barSpacing: 1, + samples: 200, + waveformColor, + progressColor, + showTime: true, + showPlaybackSpeed: false, + autoplay: false, + enableMediaSession: true, + onTimeUpdate: (ct: number) => { + const tl = timelineRef.current + const item = tl.find(t => ct >= t.startTime && ct < t.endTime) + setCurrentSeg(item?.seg ?? null) + }, + }) + playerRef.current = player + + setTimeout(() => { + containerRef.current?.querySelectorAll('button').forEach(btn => { + if (!btn.hasAttribute('type')) btn.setAttribute('type', 'button') + }) + }, 0) + + return () => { + if (playerRef.current) { + playerRef.current.destroy() + playerRef.current = null + } + } + }, [blobUrl, theme]) + + useEffect(() => { + return () => { + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current) + } + }, []) + + return ( +
+
+ + {chapterTitle} + {isLoadingTimeline && !isLoadingChapter && ( + + 字幕轨道加载中… + + )} + + +
+ + {isLoadingChapter ? ( +
+ 合并音频中… +
+ ) : ( + <> +
+
+
+ {currentSeg && ( +
+ + {currentSeg.character_name || '未知角色'} + + {currentSeg.text} +
+ )} + + )} +
+ ) +}) +ChapterPlayer.displayName = 'ChapterPlayer' +export { ChapterPlayer } diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index 789f65d..54d939c 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame } from 'lucide-react' +import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame, Headphones } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' @@ -10,6 +10,7 @@ import { Progress } from '@/components/ui/progress' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Navbar } from '@/components/Navbar' import { AudioPlayer } from '@/components/AudioPlayer' +import { ChapterPlayer } from '@/components/ChapterPlayer' import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest, type NsfwScriptGenerationRequest } from '@/lib/api/audiobook' import { RotateCcw } from 'lucide-react' import apiClient, { formatApiError, adminApi, authApi } from '@/lib/api' @@ -1443,6 +1444,7 @@ function ChaptersPanel({ const [savingSegId, setSavingSegId] = useState(null) const [regeneratingSegs, setRegeneratingSegs] = useState>(new Set()) const [audioVersions, setAudioVersions] = useState>({}) + const [chapterPlayerChIdx, setChapterPlayerChIdx] = useState(null) const prevSegStatusRef = useRef>({}) const initialExpandDoneRef = useRef(false) @@ -1670,6 +1672,15 @@ function ChaptersPanel({ }}> {t('projectCard.chapters.generate')} + @@ -1827,6 +1838,23 @@ function ChaptersPanel({
)} + {chapterPlayerChIdx !== null && (() => { + const activeCh = detail?.chapters.find(c => c.chapter_index === chapterPlayerChIdx) + const activeSegs = segments.filter(s => s.chapter_index === chapterPlayerChIdx) + if (!activeCh) return null + return ( +
+ setChapterPlayerChIdx(null)} + /> +
+ ) + })()} + {doneCount > 0 && (