feat: implement ChapterPlayer styling and refactor UI components for improved layout and functionality

This commit is contained in:
2026-03-13 16:40:54 +08:00
parent d7d86adbd5
commit 6a5eae86ce
3 changed files with 174 additions and 55 deletions

View 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;
}

View File

@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { X, Loader2 } from 'lucide-react' import { X, Loader2 } from 'lucide-react'
import apiClient from '@/lib/api' import apiClient from '@/lib/api'
import { audiobookApi, type AudiobookSegment } from '@/lib/api/audiobook' import { audiobookApi, type AudiobookSegment } from '@/lib/api/audiobook'
import styles from './AudioPlayer.module.css' import styles from './ChapterPlayer.module.css'
interface TimelineItem { interface TimelineItem {
seg: AudiobookSegment seg: AudiobookSegment
@@ -117,15 +117,15 @@ const ChapterPlayer = memo(({
} }
const waveformColor = theme === 'dark' ? '#4b5563' : '#d1d5db' const waveformColor = theme === 'dark' ? '#4b5563' : '#d1d5db'
const progressColor = theme === 'dark' ? '#a78bfa' : '#7c3aed' const progressColor = '#f97316'
const player = new WaveformPlayer(containerRef.current, { const player = new WaveformPlayer(containerRef.current, {
url: blobUrl, url: blobUrl,
waveformStyle: 'mirror', waveformStyle: 'mirror',
height: 60, height: 56,
barWidth: 3, barWidth: 2,
barSpacing: 1, barSpacing: 1,
samples: 200, samples: 260,
waveformColor, waveformColor,
progressColor, progressColor,
showTime: true, showTime: true,
@@ -161,17 +161,16 @@ const ChapterPlayer = memo(({
}, []) }, [])
return ( return (
<div className="px-3 py-2.5 border-b bg-primary/5"> <div className={styles.panel}>
<div className="flex items-center justify-between mb-2"> <div className={styles.header}>
<span className="text-xs font-medium flex items-center gap-2 min-w-0"> <span className={styles.title}></span>
<span className="truncate text-foreground/80">{chapterTitle}</span> <span className={styles.chapterName}>{chapterTitle}</span>
{isLoadingTimeline && !isLoadingChapter && ( {isLoadingTimeline && !isLoadingChapter && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground shrink-0"> <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" /> <Loader2 className="h-2.5 w-2.5 animate-spin" />
</span> </span>
)} )}
</span> <Button type="button" size="icon" variant="ghost" className="h-5 w-5 shrink-0" onClick={onClose}>
<Button type="button" size="icon" variant="ghost" className="h-5 w-5" onClick={onClose}>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
@@ -182,13 +181,13 @@ const ChapterPlayer = memo(({
</div> </div>
) : ( ) : (
<> <>
<div className={styles.audioPlayerWrapper}> <div className={styles.waveformWrapper}>
<div ref={containerRef} className={styles.waveformContainer} /> <div ref={containerRef} className={styles.waveformInner} />
</div> </div>
{currentSeg && ( {currentSeg && (
<div className="mt-2 px-1 flex items-baseline gap-1.5"> <div className={styles.subtitle}>
<span className="text-[11px] font-semibold text-primary shrink-0">{currentSeg.character_name || '旁白'}</span> <span className={styles.subtitleName}>{currentSeg.character_name || '旁白'}</span>
<span className="text-xs text-muted-foreground leading-relaxed">{currentSeg.text}</span> <span className={styles.subtitleText}>{currentSeg.text}</span>
</div> </div>
)} )}
</> </>

View File

@@ -1448,13 +1448,15 @@ function ChaptersPanel({
const prevSegStatusRef = useRef<Record<number, string>>({}) const prevSegStatusRef = useRef<Record<number, string>>({})
const initialExpandDoneRef = useRef(false) const initialExpandDoneRef = useRef(false)
const segRefs = useRef<Record<number, HTMLDivElement | null>>({}) const segRefs = useRef<Record<number, HTMLDivElement | null>>({})
const scrollContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (!scrollToChapterId) return if (!scrollToChapterId) return
setExpandedChapters(prev => { const n = new Set(prev); n.add(scrollToChapterId); return n }) const el = document.getElementById(`ch-${scrollToChapterId}`)
setTimeout(() => { const container = scrollContainerRef.current
document.getElementById(`ch-${scrollToChapterId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) if (el && container) {
}, 50) container.scrollTo({ top: el.offsetTop, behavior: 'smooth' })
}
onScrollToChapterDone() onScrollToChapterDone()
}, [scrollToChapterId, onScrollToChapterDone]) }, [scrollToChapterId, onScrollToChapterDone])
@@ -1582,24 +1584,24 @@ function ChaptersPanel({
{hasChapters && ( {hasChapters && (
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
{detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && !isBackgroundGenerating && ( {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" />} {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')} {t(isAIMode ? 'projectCard.chapters.parseAllAI' : 'projectCard.chapters.parseAll')}
</Button> </Button>
)} )}
{detail!.chapters.some(c => c.status === 'ready') && ( {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" /> <Volume2 className="h-3 w-3 mr-1" />
{t('projectCard.chapters.generateAll')} {t('projectCard.chapters.generateAll')}
</Button> </Button>
)} )}
{detail!.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail!.chapters.some(c => c.status === 'ready') && ( {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')} {loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
</Button> </Button>
)} )}
{isAIMode && onContinueScript && ( {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" /> <Plus className="h-3 w-3 mr-1" />
{t('projectCard.chapters.continueScript')} {t('projectCard.chapters.continueScript')}
</Button> </Button>
@@ -1611,7 +1613,7 @@ function ChaptersPanel({
{!hasChapters ? ( {!hasChapters ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" /> <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 => { {detail!.chapters.map(ch => {
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index) const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
const chDone = chSegs.filter(s => s.status === 'done').length const chDone = chSegs.filter(s => s.status === 'done').length
@@ -1634,7 +1636,7 @@ function ChaptersPanel({
onClick={toggleChExpand} onClick={toggleChExpand}
> >
<span className="shrink-0 text-muted-foreground"> <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>
<span className="text-xs font-medium flex-1 truncate">{chTitle}</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()}> <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" /> <Loader2 className="h-3 w-3 animate-spin" />
</span> </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" />} {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')} {t(isAIMode ? 'projectCard.chapters.parseAI' : 'projectCard.chapters.parse')}
</Button> </Button>
@@ -1657,14 +1659,14 @@ function ChaptersPanel({
)} )}
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && ( {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 }) setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
onGenerate(ch.chapter_index) onGenerate(ch.chapter_index)
}}> }}>
<Volume2 className="h-3 w-3 mr-1" /> <Volume2 className="h-3 w-3 mr-1" />
{t('projectCard.chapters.generate')} {t('projectCard.chapters.generate')}
</Button> </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" />} {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')} {t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
</Button> </Button>
@@ -1678,7 +1680,7 @@ function ChaptersPanel({
{(ch.status === 'done' || (ch.status === 'ready' && chAllDone)) && ( {(ch.status === 'done' || (ch.status === 'ready' && chAllDone)) && (
<> <>
<span className="text-[11px] text-muted-foreground">{t('projectCard.chapters.doneBadge', { count: chDone })}</span> <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 }) setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
onGenerate(ch.chapter_index, true) onGenerate(ch.chapter_index, true)
}}> }}>
@@ -1699,7 +1701,7 @@ function ChaptersPanel({
</> </>
)} )}
{ch.status === 'error' && ( {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" />} {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')} {t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
</Button> </Button>
@@ -1729,24 +1731,19 @@ function ChaptersPanel({
> >
{/* Card header */} {/* Card header */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50"> <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"> <span className="text-xs text-muted-foreground shrink-0">
{seg.character_name || t('projectCard.segments.unknownCharacter')} <span className="text-sm text-foreground">{seg.character_name || t('projectCard.segments.unknownCharacter')}</span>
</Badge> {!isEditing && seg.emo_text && (
{!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 => { {seg.emo_text.split('+').map((tok, i) => {
const [name, w] = tok.split(':') const [name, w] = tok.split(':')
return ( return <span key={tok}>{i > 0 ? ' ' : ''}{name.trim()}{w ? `:${parseFloat(w).toFixed(2)}` : ''}</span>
<span key={tok} className="bg-muted rounded px-1"> })}
{name.trim()}{w && <span className="opacity-60">:{parseFloat(w).toFixed(2)}</span>} {seg.emo_alpha != null && seg.emo_alpha !== 1 && ` :${seg.emo_alpha.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>
)}
{isRegenerating && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />} {isRegenerating && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
{!isRegenerating && seg.status === 'error' && ( {!isRegenerating && seg.status === 'error' && (
<Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge> <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) const activeSegs = segments.filter(s => s.chapter_index === chapterPlayerChIdx)
if (!activeCh) return null if (!activeCh) return null
return ( return (
<div className="border-t shrink-0"> <div className="shrink-0">
<ChapterPlayer <ChapterPlayer
projectId={project.id} projectId={project.id}
chapterIndex={chapterPlayerChIdx} chapterIndex={chapterPlayerChIdx}