feat(korea): 중국어 경고문 TTS 음성 재생 (Web Speech API)

- 경고문 옆 🔊 버튼 클릭 → 중국어(zh-CN) 음성 재생
- SpeechSynthesis API 사용 (브라우저 내장, API 키 불필요)
- 재생 중 버튼 애니메이션 (pulse) + 배경 하이라이트
- 재생 속도 0.85x (확성기 방송용 느린 발화)
- 클릭: 클립보드 복사 / 🔊: 음성 재생 분리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-24 15:54:43 +09:00
부모 1aa887fce4
커밋 8b74f455df

파일 보기

@ -137,10 +137,29 @@ export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props)
const criticalCount = suspects.filter(s => s.riskLevel === 'CRITICAL').length;
const highCount = suspects.filter(s => s.riskLevel === 'HIGH').length;
const [speakingIdx, setSpeakingIdx] = useState<number | null>(null);
const copyToClipboard = (text: string, idx: number) => {
navigator.clipboard.writeText(text).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1500); });
};
const speakChinese = useCallback((text: string, idx: number) => {
if (typeof window === 'undefined' || !window.speechSynthesis) return;
window.speechSynthesis.cancel();
const utter = new SpeechSynthesisUtterance(text);
utter.lang = 'zh-CN';
utter.rate = 0.85;
utter.volume = 1;
// Try to find a Chinese voice
const voices = window.speechSynthesis.getVoices();
const zhVoice = voices.find(v => v.lang.startsWith('zh')) || voices.find(v => v.lang.includes('CN'));
if (zhVoice) utter.voice = zhVoice;
utter.onstart = () => setSpeakingIdx(idx);
utter.onend = () => setSpeakingIdx(null);
utter.onerror = () => setSpeakingIdx(null);
window.speechSynthesis.speak(utter);
}, []);
const handleSuspectClick = (s: SuspectVessel) => {
setSelectedSuspect(s);
setTab('procedure');
@ -244,16 +263,35 @@ export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props)
{/* 중국어 경고문 */}
<div style={{ marginTop: 12, borderTop: '1px solid #1e293b', paddingTop: 8 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#f59e0b', marginBottom: 6 }}>📢 ( )</div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#f59e0b', marginBottom: 6 }}>📢 (클릭: 복사 | 🔊: )</div>
{(CN_WARNINGS[selectedSuspect.estimatedType] || CN_WARNINGS.UNKNOWN).map((w, i) => (
<div key={i} onClick={() => copyToClipboard(w.zh, i)} style={{
background: copiedIdx === i ? 'rgba(34,197,94,0.15)' : '#111827',
border: copiedIdx === i ? '1px solid rgba(34,197,94,0.4)' : '1px solid #1e293b',
borderRadius: 4, padding: '6px 10px', marginBottom: 4, cursor: 'pointer', transition: 'all 0.2s',
<div key={i} style={{
background: copiedIdx === i ? 'rgba(34,197,94,0.15)' : speakingIdx === i ? 'rgba(251,191,36,0.1)' : '#111827',
border: copiedIdx === i ? '1px solid rgba(34,197,94,0.4)' : speakingIdx === i ? '1px solid rgba(251,191,36,0.4)' : '1px solid #1e293b',
borderRadius: 4, padding: '6px 10px', marginBottom: 4, transition: 'all 0.2s',
display: 'flex', alignItems: 'flex-start', gap: 8,
}}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fbbf24' }}>{w.zh}</div>
<div style={{ fontSize: 9, color: '#94a3b8' }}>{w.ko}</div>
<div style={{ fontSize: 8, color: '#475569' }}>: {w.usage} {copiedIdx === i && <span style={{ color: '#22c55e', fontWeight: 700 }}> </span>}</div>
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => copyToClipboard(w.zh, i)}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fbbf24' }}>{w.zh}</div>
<div style={{ fontSize: 9, color: '#94a3b8' }}>{w.ko}</div>
<div style={{ fontSize: 8, color: '#475569' }}>
: {w.usage}
{copiedIdx === i && <span style={{ color: '#22c55e', fontWeight: 700, marginLeft: 4 }}> </span>}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); speakChinese(w.zh, i); }}
style={{
background: speakingIdx === i ? 'rgba(251,191,36,0.3)' : 'rgba(251,191,36,0.1)',
border: '1px solid rgba(251,191,36,0.3)',
borderRadius: 4, padding: '4px 8px', cursor: 'pointer',
fontSize: 14, lineHeight: 1, flexShrink: 0,
animation: speakingIdx === i ? 'pulse 1s ease-in-out infinite' : 'none',
}}
title="중국어 음성 재생"
>
{speakingIdx === i ? '🔊' : '🔈'}
</button>
</div>
))}
</div>