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 { X, Loader2 } from 'lucide-react' import apiClient from '@/lib/api' import { audiobookApi, type AudiobookSegment } from '@/lib/api/audiobook' import styles from './ChapterPlayer.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 = '#f97316' const player = new WaveformPlayer(containerRef.current, { url: blobUrl, waveformStyle: 'mirror', height: 56, barWidth: 2, barSpacing: 1, samples: 260, 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 }