refactor: 统一音频播放按钮样式,修复配音生成状态判断
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user