200 lines
6.0 KiB
TypeScript
200 lines
6.0 KiB
TypeScript
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<string>('')
|
|
const [isLoadingChapter, setIsLoadingChapter] = useState(true)
|
|
const [isLoadingTimeline, setIsLoadingTimeline] = useState(false)
|
|
const [currentSeg, setCurrentSeg] = useState<AudiobookSegment | null>(null)
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const playerRef = useRef<WaveformPlayer | null>(null)
|
|
const timelineRef = useRef<TimelineItem[]>([])
|
|
const blobUrlRef = useRef<string>('')
|
|
|
|
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<number>(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 (
|
|
<div className={styles.panel}>
|
|
<div className={styles.header}>
|
|
<span className={styles.title}>正在播放</span>
|
|
<span className={styles.chapterName}>{chapterTitle}</span>
|
|
{isLoadingTimeline && !isLoadingChapter && (
|
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground shrink-0 ml-auto">
|
|
<Loader2 className="h-2.5 w-2.5 animate-spin" />字幕轨道加载中…
|
|
</span>
|
|
)}
|
|
<Button type="button" size="icon" variant="ghost" className="h-5 w-5 shrink-0" onClick={onClose}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{isLoadingChapter ? (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground py-3">
|
|
<Loader2 className="h-3 w-3 animate-spin" />合并音频中…
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className={styles.waveformWrapper}>
|
|
<div ref={containerRef} className={styles.waveformInner} />
|
|
</div>
|
|
{currentSeg && (
|
|
<div className={styles.subtitle}>
|
|
<span className={styles.subtitleName}>{currentSeg.character_name || '旁白'}</span>
|
|
<span className={styles.subtitleText}>{currentSeg.text}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
ChapterPlayer.displayName = 'ChapterPlayer'
|
|
export { ChapterPlayer }
|