feat: add ChapterPlayer component for audio chapter playback in Audiobook

This commit is contained in:
2026-03-13 16:21:11 +08:00
parent 786254cb81
commit 3393be4967
2 changed files with 232 additions and 1 deletions

View File

@@ -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<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 = 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 (
<div className="px-3 py-2.5 border-b bg-primary/5">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium flex items-center gap-2 min-w-0">
<span className="truncate text-foreground/80">{chapterTitle}</span>
{isLoadingTimeline && !isLoadingChapter && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
)}
</span>
<Button type="button" size="icon" variant="ghost" className="h-5 w-5" 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.audioPlayerWrapper}>
<div ref={containerRef} className={styles.waveformContainer} />
</div>
{currentSeg && (
<div className="mt-2 flex items-start gap-2 p-2 rounded-md bg-background border border-border/60">
<Badge variant="outline" className="shrink-0 text-xs font-normal mt-0.5">
{currentSeg.character_name || '未知角色'}
</Badge>
<span className="text-xs leading-relaxed text-foreground/80">{currentSeg.text}</span>
</div>
)}
</>
)}
</div>
)
})
ChapterPlayer.displayName = 'ChapterPlayer'
export { ChapterPlayer }