feat: add ChapterPlayer component for audio chapter playback in Audiobook
This commit is contained in:
203
qwen3-tts-frontend/src/components/ChapterPlayer.tsx
Normal file
203
qwen3-tts-frontend/src/components/ChapterPlayer.tsx
Normal 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 }
|
||||
@@ -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<number | null>(null)
|
||||
const [regeneratingSegs, setRegeneratingSegs] = useState<Set<number>>(new Set())
|
||||
const [audioVersions, setAudioVersions] = useState<Record<number, number>>({})
|
||||
const [chapterPlayerChIdx, setChapterPlayerChIdx] = useState<number | null>(null)
|
||||
const prevSegStatusRef = useRef<Record<number, string>>({})
|
||||
const initialExpandDoneRef = useRef(false)
|
||||
|
||||
@@ -1670,6 +1672,15 @@ function ChaptersPanel({
|
||||
}}>
|
||||
<RefreshCw className="h-3 w-3 mr-0.5" /><Volume2 className="h-3 w-3 mr-1" />{t('projectCard.chapters.generate')}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={chapterPlayerChIdx === ch.chapter_index ? 'secondary' : 'ghost'}
|
||||
className="h-6 w-6"
|
||||
onClick={() => setChapterPlayerChIdx(prev => prev === ch.chapter_index ? null : ch.chapter_index)}
|
||||
title="播放本章"
|
||||
>
|
||||
<Headphones className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
|
||||
<Download className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -1827,6 +1838,23 @@ function ChaptersPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<div className="border-t shrink-0">
|
||||
<ChapterPlayer
|
||||
projectId={project.id}
|
||||
chapterIndex={chapterPlayerChIdx}
|
||||
chapterTitle={activeCh.title || t('projectCard.chapters.defaultTitle', { index: chapterPlayerChIdx + 1 })}
|
||||
segments={activeSegs}
|
||||
onClose={() => setChapterPlayerChIdx(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{doneCount > 0 && (
|
||||
<div className="px-3 py-2 border-t shrink-0">
|
||||
<SequentialPlayer segments={segments} projectId={project.id} onPlayingChange={onSequentialPlayingChange} />
|
||||
|
||||
Reference in New Issue
Block a user