refactor: 统一音频播放按钮样式,修复配音生成状态判断

This commit is contained in:
小鱼开发
2026-04-22 10:37:07 +08:00
parent 3bf7e92b61
commit ab28c3d963
4 changed files with 214 additions and 135 deletions
@@ -84,6 +84,94 @@
margin-bottom: var(--spacing-xs);
}
/* ============================================================
音频素材列表 — 复用 VoiceDubbing 的 voice-row 样式
============================================================ */
.voice-row {
display: flex;
flex-direction: column;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-card);
cursor: default;
transition: all var(--transition-fast);
}
.voice-row:hover {
border-color: var(--primary);
background: var(--bg-hover);
}
.voice-row-main {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.voice-row-info {
flex: 1;
min-width: 0;
}
.voice-row-name {
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 6px;
}
.voice-row-desc-inline {
font-size: var(--font-xs);
color: var(--text-secondary);
margin-left: 8px;
font-weight: 400;
}
.voice-row-actions {
display: flex;
align-items: center;
gap: var(--spacing-xs);
flex-shrink: 0;
}
/* 图标按钮 — 播放/编辑/删除统一样式 */
.preview-icon,
.action-icon {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: var(--bg-input);
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
line-height: 1;
font-variant-emoji: text;
cursor: pointer;
flex-shrink: 0;
transition: all var(--transition-fast);
}
.preview-icon:hover,
.action-icon:hover {
background: var(--primary);
color: var(--text-inverse);
}
.preview-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 行内编辑输入框 */
.voice-name-input {
padding: var(--spacing-xs) var(--spacing-sm);
@@ -502,90 +502,58 @@ export default function VoiceMaterialLibrary() {
</div>
)}
{voiceMaterials.slice((audioPage - 1) * pageSize, audioPage * pageSize).map(m => (
<div
key={m.id}
className="voice-card"
style={{
padding: 'var(--spacing-md) var(--spacing-lg)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-color)',
background: 'var(--bg-card)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
height: 'fit-content',
}}
>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
{editingId === m.id ? (
<input
type="text"
className="input"
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') confirmRename();
if (e.key === 'Escape') cancelRename();
}}
onBlur={confirmRename}
autoFocus
style={{ width: '100%', height: 28, padding: '2px 8px', fontSize: 'var(--font-sm)' }}
/>
) : (
<>
<div style={{ fontWeight: 500, fontSize: 'var(--font-sm)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<div key={m.id} className="voice-row" style={{ cursor: 'default' }}>
<div className="voice-row-main">
<div className="voice-row-info">
{editingId === m.id ? (
<input
type="text"
className="input"
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') confirmRename();
if (e.key === 'Escape') cancelRename();
}}
onBlur={confirmRename}
autoFocus
style={{ width: '100%', height: 28, padding: '2px 8px', fontSize: 'var(--font-sm)' }}
/>
) : (
<div className="voice-row-name">
{m.name}
<span className="voice-row-desc-inline">{statusLabel(m.status)}</span>
</div>
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{statusLabel(m.status)}
</div>
</>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{m.sourceUrl && (
)}
</div>
<div className="voice-row-actions">
{m.sourceUrl && (
<button
className="preview-icon"
onClick={e => {
e.stopPropagation();
togglePlay(m.id, m.sourceUrl);
}}
title={playingId === m.id ? '暂停' : '播放'}
>
{playingId === m.id ? '⏸' : '▶'}
</button>
)}
<button
className="btn btn-ghost"
style={{
padding: 0,
width: 36,
height: 36,
borderRadius: '50%',
background: playingId === m.id ? 'var(--primary-color)' : 'var(--bg-secondary)',
color: playingId === m.id ? '#fff' : 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => togglePlay(m.id, m.sourceUrl)}
title={playingId === m.id ? '暂停' : '播放'}
className="action-icon"
onClick={() => startRename(m.id, m.name)}
title="重命名"
>
{playingId === m.id ? '⏸' : '▶'}
</button>
)}
<button
className="btn btn-ghost"
style={{ padding: '4px', width: 28, height: 28 }}
onClick={() => startRename(m.id, m.name)}
title="重命名"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
className="btn btn-ghost"
style={{ padding: '4px', width: 28, height: 28, color: 'var(--text-tertiary)' }}
onClick={() => openDeleteModal(m.id, m.name, 'audio')}
title="删除"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
<button
className="action-icon"
onClick={() => openDeleteModal(m.id, m.name, 'audio')}
title="删除"
>
</button>
</div>
</div>
</div>
))}
@@ -191,7 +191,9 @@
border-radius: var(--radius-md);
background: var(--bg-input);
color: var(--text-secondary);
font-size: var(--font-xs);
font-size: 13px;
line-height: 1;
font-variant-emoji: text;
cursor: pointer;
flex-shrink: 0;
transition: all var(--transition-fast);
@@ -303,13 +305,7 @@
line-height: 1.8;
}
/* 内嵌试听播放器 */
/* 试听播放器(当前由右侧图标按钮直接控制,无需展开区域) */
.voice-preview-inline {
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--border-light);
}
.voice-preview-inline .voice-preview-audio {
width: 100%;
display: none;
}
@@ -5,7 +5,7 @@
* 布局:左侧窄栏(音色 + 语速 + 生成按钮固定底部)| 右侧宽栏(配音文案)
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useProjectStore } from '../../store';
import { useVoiceStore } from '../../store/voiceStore';
import { getCurrentProjectId } from '../../api/modules/localStorage';
@@ -39,18 +39,15 @@ export default function VoiceDubbing() {
const [isGenerating, setIsGenerating] = useState(false);
const [activeVoiceTab, setActiveVoiceTab] = useState<'preset' | 'clone'>('preset');
const [activePreviewVoiceId, setActivePreviewVoiceId] = useState<string | null>(null);
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const audioInstanceRef = useRef<HTMLAudioElement | null>(null);
// 判断当前是否已有生成的配音
const hasGeneratedAudio = useMemo(
() => segments.some(s => s.audioUrl),
[segments]
);
// 取第一个有 audioUrl 的分镜作为播放源
const generatedAudioUrl = useMemo(
() => segments.find(s => s.audioUrl)?.audioUrl || null,
[segments]
);
// 当前项目已生成的配音 URL(项目级别,不存于各分镜)
const [generatedAudioUrl, setGeneratedAudioUrl] = useState<string | null>(null);
const [isPlayingGenerated, setIsPlayingGenerated] = useState(false);
const generatedAudioRef = useRef<HTMLAudioElement | null>(null);
const hasGeneratedAudio = !!generatedAudioUrl;
useEffect(() => {
loadPresetVoices();
@@ -64,20 +61,39 @@ export default function VoiceDubbing() {
);
const totalChars = mergedText.length;
const handleTogglePreview = useCallback((voiceId: string, voiceName: string, e: React.MouseEvent) => {
const handlePlayPause = useCallback((voiceId: string, url: string | null, e: React.MouseEvent) => {
e.stopPropagation();
// 点击同一个就是关闭
if (activePreviewVoiceId === voiceId) {
setActivePreviewVoiceId(null);
if (!url) return;
if (playingVoiceId === voiceId) {
// 暂停当前
audioInstanceRef.current?.pause();
setPlayingVoiceId(null);
audioInstanceRef.current = null;
return;
}
setActivePreviewVoiceId(voiceId);
}, [activePreviewVoiceId]);
const getPreviewUrl = (voiceId: string): string | null => {
const voice = presetVoices.find(v => v.voiceId === voiceId);
return voice?.previewUrl || null;
};
// 停止之前的
audioInstanceRef.current?.pause();
// 播放新的
const audio = new Audio(url);
audio.onended = () => {
if (audioInstanceRef.current === audio) {
setPlayingVoiceId(null);
audioInstanceRef.current = null;
}
};
audio.onpause = () => {
if (audioInstanceRef.current === audio) {
setPlayingVoiceId(null);
audioInstanceRef.current = null;
}
};
audio.play();
audioInstanceRef.current = audio;
setPlayingVoiceId(voiceId);
}, [playingVoiceId]);
const handleGenerate = useCallback(async () => {
if (!projectId) { toast.warning('请先创建项目'); return; }
@@ -137,6 +153,8 @@ export default function VoiceDubbing() {
setAudioMapping(segId.toString(), meta.id);
}
}
setGeneratedAudioUrl(qiniuUrl);
progress.success('配音生成完成');
} catch (err) {
progress.error(err instanceof Error ? err.message : '生成失败');
@@ -145,6 +163,30 @@ export default function VoiceDubbing() {
}
}, [projectId, segments, selectedVoiceId, speed, volume, pitch, setAudioMapping, updateSegment]);
const handleToggleGeneratedAudio = useCallback(() => {
if (!generatedAudioUrl) return;
if (isPlayingGenerated) {
generatedAudioRef.current?.pause();
setIsPlayingGenerated(false);
generatedAudioRef.current = null;
return;
}
const audio = new Audio(generatedAudioUrl);
audio.onended = () => {
setIsPlayingGenerated(false);
generatedAudioRef.current = null;
};
audio.onpause = () => {
setIsPlayingGenerated(false);
generatedAudioRef.current = null;
};
audio.play();
generatedAudioRef.current = audio;
setIsPlayingGenerated(true);
}, [generatedAudioUrl, isPlayingGenerated]);
return (
<div className="voice-dubbing">
<div className="dubbing-layout">
@@ -176,15 +218,10 @@ export default function VoiceDubbing() {
<span className="voice-row-desc-inline">{v.description}</span>
</div>
</div>
<button className="preview-icon" onClick={e => handleTogglePreview(v.voiceId, v.name, e)}>
{activePreviewVoiceId === v.voiceId ? '' : '▶'}
<button className="preview-icon" onClick={e => handlePlayPause(v.voiceId, v.previewUrl, e)}>
{playingVoiceId === v.voiceId ? '' : '▶'}
</button>
</div>
{activePreviewVoiceId === v.voiceId && v.previewUrl && (
<div className="voice-preview-inline">
<audio src={v.previewUrl} controls className="voice-preview-audio" autoPlay />
</div>
)}
</div>
))}
</div>
@@ -206,15 +243,10 @@ export default function VoiceDubbing() {
</span>
</div>
</div>
<button className="preview-icon" onClick={e => handleTogglePreview(m.voiceId, m.name, e)}>
{activePreviewVoiceId === m.voiceId ? '' : '▶'}
<button className="preview-icon" onClick={e => handlePlayPause(m.voiceId, m.trialUrl, e)}>
{playingVoiceId === m.voiceId ? '' : '▶'}
</button>
</div>
{activePreviewVoiceId === m.voiceId && m.trialUrl && (
<div className="voice-preview-inline">
<audio src={m.trialUrl} controls className="voice-preview-audio" autoPlay />
</div>
)}
</div>
))
)}
@@ -299,17 +331,12 @@ export default function VoiceDubbing() {
<button className="btn btn-secondary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{isGenerating ? '合成中...' : '重新生成'}
</button>
{generatedAudioUrl && (
<button
className="btn btn-primary generate-btn"
onClick={() => {
const audio = new Audio(generatedAudioUrl);
audio.play();
}}
>
</button>
)}
<button
className="btn btn-primary generate-btn"
onClick={handleToggleGeneratedAudio}
>
{isPlayingGenerated ? '⏸ 暂停播放' : '▶ 试听播放'}
</button>
</div>
)}
</div>