feat: implement ChapterPlayer styling and refactor UI components for improved layout and functionality
This commit is contained in:
123
qwen3-tts-frontend/src/components/ChapterPlayer.module.css
Normal file
123
qwen3-tts-frontend/src/components/ChapterPlayer.module.css
Normal file
@@ -0,0 +1,123 @@
|
||||
.panel {
|
||||
padding: 0.625rem 0.75rem 0.75rem;
|
||||
background: rgb(249 115 22 / 0.12);
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.1);
|
||||
border-top: 2px solid rgb(249 115 22 / 0.4);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(249 115 22 / 0.85);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chapterName {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground) / 0.75);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.waveformWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-player) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-btn) {
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--border));
|
||||
background: transparent;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-btn:hover) {
|
||||
color: rgb(249 115 22);
|
||||
border-color: rgb(249 115 22);
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-canvas) {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-title) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-body) {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-track) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-info) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-text) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.waveformWrapper :global(.waveform-time) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.waveformInner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.subtitleName {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: rgb(249 115 22);
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.subtitleText {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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 './AudioPlayer.module.css'
|
||||
import styles from './ChapterPlayer.module.css'
|
||||
|
||||
interface TimelineItem {
|
||||
seg: AudiobookSegment
|
||||
@@ -117,15 +117,15 @@ const ChapterPlayer = memo(({
|
||||
}
|
||||
|
||||
const waveformColor = theme === 'dark' ? '#4b5563' : '#d1d5db'
|
||||
const progressColor = theme === 'dark' ? '#a78bfa' : '#7c3aed'
|
||||
const progressColor = '#f97316'
|
||||
|
||||
const player = new WaveformPlayer(containerRef.current, {
|
||||
url: blobUrl,
|
||||
waveformStyle: 'mirror',
|
||||
height: 60,
|
||||
barWidth: 3,
|
||||
height: 56,
|
||||
barWidth: 2,
|
||||
barSpacing: 1,
|
||||
samples: 200,
|
||||
samples: 260,
|
||||
waveformColor,
|
||||
progressColor,
|
||||
showTime: true,
|
||||
@@ -161,17 +161,16 @@ const ChapterPlayer = memo(({
|
||||
}, [])
|
||||
|
||||
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}>
|
||||
<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>
|
||||
@@ -182,13 +181,13 @@ const ChapterPlayer = memo(({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.audioPlayerWrapper}>
|
||||
<div ref={containerRef} className={styles.waveformContainer} />
|
||||
<div className={styles.waveformWrapper}>
|
||||
<div ref={containerRef} className={styles.waveformInner} />
|
||||
</div>
|
||||
{currentSeg && (
|
||||
<div className="mt-2 px-1 flex items-baseline gap-1.5">
|
||||
<span className="text-[11px] font-semibold text-primary shrink-0">{currentSeg.character_name || '旁白'}</span>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">{currentSeg.text}</span>
|
||||
<div className={styles.subtitle}>
|
||||
<span className={styles.subtitleName}>{currentSeg.character_name || '旁白'}</span>
|
||||
<span className={styles.subtitleText}>{currentSeg.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1448,13 +1448,15 @@ function ChaptersPanel({
|
||||
const prevSegStatusRef = useRef<Record<number, string>>({})
|
||||
const initialExpandDoneRef = useRef(false)
|
||||
const segRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollToChapterId) return
|
||||
setExpandedChapters(prev => { const n = new Set(prev); n.add(scrollToChapterId); return n })
|
||||
setTimeout(() => {
|
||||
document.getElementById(`ch-${scrollToChapterId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 50)
|
||||
const el = document.getElementById(`ch-${scrollToChapterId}`)
|
||||
const container = scrollContainerRef.current
|
||||
if (el && container) {
|
||||
container.scrollTo({ top: el.offsetTop, behavior: 'smooth' })
|
||||
}
|
||||
onScrollToChapterDone()
|
||||
}, [scrollToChapterId, onScrollToChapterDone])
|
||||
|
||||
@@ -1582,24 +1584,24 @@ function ChaptersPanel({
|
||||
{hasChapters && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && !isBackgroundGenerating && (
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onParseAll}>
|
||||
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={onParseAll}>
|
||||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||||
{t(isAIMode ? 'projectCard.chapters.parseAllAI' : 'projectCard.chapters.parseAll')}
|
||||
</Button>
|
||||
)}
|
||||
{detail!.chapters.some(c => c.status === 'ready') && (
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onGenerateAll}>
|
||||
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={onGenerateAll}>
|
||||
<Volume2 className="h-3 w-3 mr-1" />
|
||||
{t('projectCard.chapters.generateAll')}
|
||||
</Button>
|
||||
)}
|
||||
{detail!.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail!.chapters.some(c => c.status === 'ready') && (
|
||||
<Button size="xs" disabled={loadingAction} onClick={onProcessAll}>
|
||||
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={onProcessAll}>
|
||||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
||||
</Button>
|
||||
)}
|
||||
{isAIMode && onContinueScript && (
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onContinueScript}>
|
||||
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={onContinueScript}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('projectCard.chapters.continueScript')}
|
||||
</Button>
|
||||
@@ -1611,7 +1613,7 @@ function ChaptersPanel({
|
||||
{!hasChapters ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" />
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||
{detail!.chapters.map(ch => {
|
||||
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
|
||||
const chDone = chSegs.filter(s => s.status === 'done').length
|
||||
@@ -1634,7 +1636,7 @@ function ChaptersPanel({
|
||||
onClick={toggleChExpand}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{chExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" style={{ transform: 'rotate(180deg)' }} />}
|
||||
{chExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="text-xs font-medium flex-1 truncate">{chTitle}</span>
|
||||
<span className="shrink-0 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
|
||||
@@ -1644,7 +1646,7 @@ function ChaptersPanel({
|
||||
<Loader2 className="h-3 w-3 animate-spin" />排队中
|
||||
</span>
|
||||
) : (
|
||||
<Button size="xs" variant="outline" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
<Button size="xs" variant="ghost" className="text-muted-foreground" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||||
{t(isAIMode ? 'projectCard.chapters.parseAI' : 'projectCard.chapters.parse')}
|
||||
</Button>
|
||||
@@ -1657,14 +1659,14 @@ function ChaptersPanel({
|
||||
)}
|
||||
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
|
||||
<>
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => {
|
||||
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => {
|
||||
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
||||
onGenerate(ch.chapter_index)
|
||||
}}>
|
||||
<Volume2 className="h-3 w-3 mr-1" />
|
||||
{t('projectCard.chapters.generate')}
|
||||
</Button>
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||||
{t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
|
||||
</Button>
|
||||
@@ -1678,7 +1680,7 @@ function ChaptersPanel({
|
||||
{(ch.status === 'done' || (ch.status === 'ready' && chAllDone)) && (
|
||||
<>
|
||||
<span className="text-[11px] text-muted-foreground">{t('projectCard.chapters.doneBadge', { count: chDone })}</span>
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => {
|
||||
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => {
|
||||
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
||||
onGenerate(ch.chapter_index, true)
|
||||
}}>
|
||||
@@ -1699,7 +1701,7 @@ function ChaptersPanel({
|
||||
</>
|
||||
)}
|
||||
{ch.status === 'error' && (
|
||||
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
<Button size="xs" variant="ghost" className="text-destructive" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||||
{t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
|
||||
</Button>
|
||||
@@ -1729,24 +1731,19 @@ function ChaptersPanel({
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50">
|
||||
<Badge variant="outline" className="text-xs shrink-0 font-normal">
|
||||
{seg.character_name || t('projectCard.segments.unknownCharacter')}
|
||||
</Badge>
|
||||
{!isEditing && seg.emo_text && (
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 flex items-center gap-0.5 flex-wrap">
|
||||
{seg.emo_text.split('+').map(tok => {
|
||||
const [name, w] = tok.split(':')
|
||||
return (
|
||||
<span key={tok} className="bg-muted rounded px-1">
|
||||
{name.trim()}{w && <span className="opacity-60">:{parseFloat(w).toFixed(2)}</span>}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{seg.emo_alpha != null && seg.emo_alpha !== 1 && (
|
||||
<span className="opacity-60 ml-0.5">×{seg.emo_alpha.toFixed(2)}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
<span className="text-sm text-foreground">{seg.character_name || t('projectCard.segments.unknownCharacter')}</span>
|
||||
{!isEditing && seg.emo_text && (
|
||||
<>
|
||||
{' | '}
|
||||
{seg.emo_text.split('+').map((tok, i) => {
|
||||
const [name, w] = tok.split(':')
|
||||
return <span key={tok}>{i > 0 ? ' ' : ''}{name.trim()}{w ? `:${parseFloat(w).toFixed(2)}` : ''}</span>
|
||||
})}
|
||||
{seg.emo_alpha != null && seg.emo_alpha !== 1 && ` :${seg.emo_alpha.toFixed(2)}`}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{isRegenerating && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
{!isRegenerating && seg.status === 'error' && (
|
||||
<Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>
|
||||
@@ -1856,7 +1853,7 @@ function ChaptersPanel({
|
||||
const activeSegs = segments.filter(s => s.chapter_index === chapterPlayerChIdx)
|
||||
if (!activeCh) return null
|
||||
return (
|
||||
<div className="border-t shrink-0">
|
||||
<div className="shrink-0">
|
||||
<ChapterPlayer
|
||||
projectId={project.id}
|
||||
chapterIndex={chapterPlayerChIdx}
|
||||
|
||||
Reference in New Issue
Block a user