diff --git a/qwen3-tts-frontend/src/components/ChapterPlayer.module.css b/qwen3-tts-frontend/src/components/ChapterPlayer.module.css new file mode 100644 index 0000000..337f293 --- /dev/null +++ b/qwen3-tts-frontend/src/components/ChapterPlayer.module.css @@ -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; +} diff --git a/qwen3-tts-frontend/src/components/ChapterPlayer.tsx b/qwen3-tts-frontend/src/components/ChapterPlayer.tsx index 9c7444b..85adbb3 100644 --- a/qwen3-tts-frontend/src/components/ChapterPlayer.tsx +++ b/qwen3-tts-frontend/src/components/ChapterPlayer.tsx @@ -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 ( -
-
- - {chapterTitle} - {isLoadingTimeline && !isLoadingChapter && ( - - 字幕轨道加载中… - - )} - -
@@ -182,13 +181,13 @@ const ChapterPlayer = memo(({
) : ( <> -
-
+
+
{currentSeg && ( -
- {currentSeg.character_name || '旁白'} - {currentSeg.text} +
+ {currentSeg.character_name || '旁白'} + {currentSeg.text}
)} diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index d9a75ca..7936b09 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -1448,13 +1448,15 @@ function ChaptersPanel({ const prevSegStatusRef = useRef>({}) const initialExpandDoneRef = useRef(false) const segRefs = useRef>({}) + const scrollContainerRef = useRef(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 && (
{detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && !isBackgroundGenerating && ( - )} {detail!.chapters.some(c => c.status === 'ready') && ( - )} {detail!.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail!.chapters.some(c => c.status === 'ready') && ( - )} {isAIMode && onContinueScript && ( - @@ -1611,7 +1613,7 @@ function ChaptersPanel({ {!hasChapters ? (
) : ( -
+
{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} > - {chExpanded ? : } + {chExpanded ? : } {chTitle} e.stopPropagation()}> @@ -1644,7 +1646,7 @@ function ChaptersPanel({ 排队中 ) : ( - @@ -1657,14 +1659,14 @@ function ChaptersPanel({ )} {ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && ( <> - - @@ -1678,7 +1680,7 @@ function ChaptersPanel({ {(ch.status === 'done' || (ch.status === 'ready' && chAllDone)) && ( <> {t('projectCard.chapters.doneBadge', { count: chDone })} - @@ -1729,24 +1731,19 @@ function ChaptersPanel({ > {/* Card header */}
- - {seg.character_name || t('projectCard.segments.unknownCharacter')} - - {!isEditing && seg.emo_text && ( - - {seg.emo_text.split('+').map(tok => { - const [name, w] = tok.split(':') - return ( - - {name.trim()}{w && :{parseFloat(w).toFixed(2)}} - - ) - })} - {seg.emo_alpha != null && seg.emo_alpha !== 1 && ( - ×{seg.emo_alpha.toFixed(2)} - )} - - )} + + {seg.character_name || t('projectCard.segments.unknownCharacter')} + {!isEditing && seg.emo_text && ( + <> + {' | '} + {seg.emo_text.split('+').map((tok, i) => { + const [name, w] = tok.split(':') + return {i > 0 ? ' ' : ''}{name.trim()}{w ? `:${parseFloat(w).toFixed(2)}` : ''} + })} + {seg.emo_alpha != null && seg.emo_alpha !== 1 && ` :${seg.emo_alpha.toFixed(2)}`} + + )} + {isRegenerating && } {!isRegenerating && seg.status === 'error' && ( {t('projectCard.segments.errorBadge')} @@ -1856,7 +1853,7 @@ function ChaptersPanel({ const activeSegs = segments.filter(s => s.chapter_index === chapterPlayerChIdx) if (!activeCh) return null return ( -
+