[251022] fix: WebSpeech 이벤트 상태 디버그 추가
🕐 커밋 시간: 2025. 10. 22. 10:06:35 📊 변경 통계: • 총 파일: 5개 • 추가: +332줄 • 삭제: -6줄 📁 추가된 파일: + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/WebSpeechEventDebug.jsx + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/backup_251022/VoiceInputOverlay.jsx + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/backup_251022/VoiceInputOverlay.module.less 📝 수정된 파일: ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript): ✅ Added: handleMicClick() 🔄 Modified: clearAllTimers() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/WebSpeechEventDebug.jsx (javascript): ✅ Added: WebSpeechEventDebug() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/backup_251022/VoiceInputOverlay.jsx (javascript): ✅ Added: clearTimerRef(), clearIntervalRef(), clearAllTimers() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/backup_251022/VoiceInputOverlay.module.less (unknown): ✅ Added: translate()
This commit is contained in:
@@ -21,6 +21,7 @@ import VoiceNotRecognized from './modes/VoiceNotRecognized';
|
||||
import VoiceNotRecognizedCircle from './modes/VoiceNotRecognizedCircle';
|
||||
import VoicePromptScreen from './modes/VoicePromptScreen';
|
||||
import VoiceResponse from './modes/VoiceResponse';
|
||||
import WebSpeechEventDebug from './WebSpeechEventDebug';
|
||||
import css from './VoiceInputOverlay.module.less';
|
||||
|
||||
const OverlayContainer = SpotlightContainerDecorator(
|
||||
@@ -157,6 +158,10 @@ const VoiceInputOverlay = ({
|
||||
const [sttResponseText, setSttResponseText] = useState('');
|
||||
// 카운트다운 타이머 (15 -> 1)
|
||||
const [countdown, setCountdown] = useState(15);
|
||||
// WebSpeech 에러 메시지 저장
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
// WebSpeech 이벤트 로그 저장 (디버그용)
|
||||
const [webSpeechEventLogs, setWebSpeechEventLogs] = useState([]);
|
||||
// 검색 기록 관리 (localStorage 기반, 최근 5개)
|
||||
const [searchHistory, setSearchHistory] = useState(() => {
|
||||
const history = readLocalStorage(SEARCH_HISTORY_KEY, DEFAULT_SUGGESTIONS);
|
||||
@@ -233,8 +238,45 @@ const VoiceInputOverlay = ({
|
||||
webSpeechConfig
|
||||
);
|
||||
|
||||
// Redux에서 STT 결과 가져오기
|
||||
const { lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
|
||||
// Redux에서 STT 결과 및 에러 상태 가져오기
|
||||
const { lastSTTText, sttTimestamp, webSpeech } = useSelector((state) => state.voice);
|
||||
|
||||
// 🎤 WebSpeech 이벤트 로그 관리 함수
|
||||
const addWebSpeechEventLog = useCallback((event, details = '') => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const newLog = {
|
||||
id: Date.now(),
|
||||
timestamp,
|
||||
event,
|
||||
details,
|
||||
type: getWebSpeechEventType(event),
|
||||
};
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log(`[WebSpeech Event] ${event}: ${details}`);
|
||||
}
|
||||
|
||||
setWebSpeechEventLogs((prev) => {
|
||||
const updated = [newLog, ...prev];
|
||||
return updated.slice(0, 10); // 최근 10개만 유지
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 이벤트 타입별 색상 분류
|
||||
const getWebSpeechEventType = useCallback((event) => {
|
||||
const types = {
|
||||
INIT: 'info',
|
||||
START: 'success',
|
||||
RESULT: 'info',
|
||||
RESULT_FINAL: 'success',
|
||||
ERROR: 'error',
|
||||
END: 'warning',
|
||||
STOP: 'warning',
|
||||
ABORT: 'error',
|
||||
RESTART: 'info',
|
||||
};
|
||||
return types[event] || 'info';
|
||||
}, []);
|
||||
|
||||
// Redux에서 shopperHouse 검색 결과 가져오기 (simplified ref usage)
|
||||
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
|
||||
@@ -265,6 +307,137 @@ const VoiceInputOverlay = ({
|
||||
}
|
||||
}, [shopperHouseData, isVisible]);
|
||||
|
||||
// 🚨 WebSpeech 에러 감시 및 처리 (Phase 1)
|
||||
useEffect(() => {
|
||||
if (webSpeech.error) {
|
||||
console.error('[VoiceInput] 🔴 WebSpeech error detected:', webSpeech.error);
|
||||
console.error('[VoiceInput] ├─ error type:', webSpeech.error);
|
||||
console.error('[VoiceInput] ├─ message:', webSpeech.message);
|
||||
console.error('[VoiceInput] ├─ current mode:', currentMode);
|
||||
console.error('[VoiceInput] └─ is listening:', isListening);
|
||||
|
||||
// WebSpeech 이벤트 로그 기록
|
||||
addWebSpeechEventLog('ERROR', `${webSpeech.error}: ${webSpeech.message || 'No message'}`);
|
||||
|
||||
// 사용자에게 표시할 에러 메시지 생성
|
||||
let userErrorMessage = '음성 인식에 문제가 발생했습니다.';
|
||||
if (webSpeech.error === 'no-speech') {
|
||||
userErrorMessage = '음성이 감지되지 않았습니다. 다시 시도해주세요.';
|
||||
} else if (webSpeech.error === 'audio-capture') {
|
||||
userErrorMessage = '마이크에 접근할 수 없습니다. 설정을 확인해주세요.';
|
||||
} else if (webSpeech.error === 'not-allowed') {
|
||||
userErrorMessage = '마이크 사용 권한이 필요합니다. 권한을 허용해주세요.';
|
||||
} else if (webSpeech.error === 'network') {
|
||||
userErrorMessage = '네트워크 연결을 확인해주세요.';
|
||||
}
|
||||
|
||||
setErrorMessage(userErrorMessage);
|
||||
|
||||
// 에러 발생시 모든 타이머 정리
|
||||
clearTimerRef(listeningTimerRef);
|
||||
clearTimerRef(silenceDetectionTimerRef);
|
||||
clearIntervalRef(countdownIntervalRef);
|
||||
|
||||
// Ref 초기화
|
||||
listeningTimerRef.current = null;
|
||||
silenceDetectionTimerRef.current = null;
|
||||
countdownIntervalRef.current = null;
|
||||
|
||||
// 음성 인식 중지
|
||||
if (isListening) {
|
||||
console.log('[VoiceInput] 🛑 Stopping listening due to error');
|
||||
stopListening();
|
||||
}
|
||||
|
||||
// 에러 모드로 전환 (NOTRECOGNIZED) - 사용자에게 에러 상태 표시
|
||||
if (isVisible) {
|
||||
console.log('[VoiceInput] 🔀 Switching to NOTRECOGNIZED mode due to error');
|
||||
setCurrentMode(VOICE_MODES.NOTRECOGNIZED);
|
||||
setVoiceInputMode(null);
|
||||
|
||||
// 일부 에러 타입은 자동 재시작 시도
|
||||
const autoRetryErrors = ['network', 'aborted', 'service-not-allowed'];
|
||||
if (autoRetryErrors.includes(webSpeech.error)) {
|
||||
console.log('[VoiceInput] 🔄 Auto-restarting for error type:', webSpeech.error);
|
||||
setTimeout(() => {
|
||||
restartWebSpeech();
|
||||
}, 2000); // 2초 후 자동 재시작
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 에러가 없으면 메시지 정리
|
||||
setErrorMessage('');
|
||||
}
|
||||
}, [
|
||||
webSpeech.error,
|
||||
currentMode,
|
||||
isListening,
|
||||
isVisible,
|
||||
stopListening,
|
||||
restartWebSpeech,
|
||||
addWebSpeechEventLog,
|
||||
]);
|
||||
|
||||
// 🎤 WebSpeech 이벤트 감지 (전용 디버그용)
|
||||
|
||||
// WebSpeech 시작 감지
|
||||
useEffect(() => {
|
||||
if (isListening && currentMode === VOICE_MODES.LISTENING) {
|
||||
addWebSpeechEventLog('START', `Listening started - Mode: ${currentMode}`);
|
||||
}
|
||||
}, [isListening, currentMode, addWebSpeechEventLog]);
|
||||
|
||||
// WebSpeech 종료 감지
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isListening &&
|
||||
currentMode === VOICE_MODES.LISTENING &&
|
||||
webSpeechEventLogs.some((log) => log.event === 'START')
|
||||
) {
|
||||
addWebSpeechEventLog('END', 'Listening ended naturally');
|
||||
}
|
||||
}, [isListening, currentMode, webSpeechEventLogs, addWebSpeechEventLog]);
|
||||
|
||||
// Interim 텍스트 변화 감지
|
||||
useEffect(() => {
|
||||
if (interimText && interimText.trim().length > 0) {
|
||||
const lastLog = webSpeechEventLogs[0];
|
||||
const now = Date.now();
|
||||
|
||||
// 마지막 RESULT 로그가 1초 이내면 스킵 (과도한 로그 방지)
|
||||
if (!lastLog || lastLog.event !== 'RESULT' || now - lastLog.id > 1000) {
|
||||
const truncated =
|
||||
interimText.length > 30 ? interimText.substring(0, 30) + '...' : interimText;
|
||||
addWebSpeechEventLog('RESULT', `Interim: "${truncated}"`);
|
||||
}
|
||||
}
|
||||
}, [interimText, webSpeechEventLogs, addWebSpeechEventLog]);
|
||||
|
||||
// 마이크 버튼 클릭 감지 (수동 중지)
|
||||
useEffect(() => {
|
||||
const micButtonElement = document.getElementById(MIC_SPOTLIGHT_ID);
|
||||
|
||||
const handleMicClick = () => {
|
||||
if (isListening) {
|
||||
addWebSpeechEventLog('STOP', 'Manually stopped by user');
|
||||
}
|
||||
};
|
||||
|
||||
if (micButtonElement) {
|
||||
micButtonElement.addEventListener('click', handleMicClick);
|
||||
return () => {
|
||||
micButtonElement.removeEventListener('click', handleMicClick);
|
||||
};
|
||||
}
|
||||
}, [isListening, addWebSpeechEventLog]);
|
||||
|
||||
// Overlay 닫을 때 감지
|
||||
useEffect(() => {
|
||||
if (!isVisible && isListening) {
|
||||
addWebSpeechEventLog('ABORT', 'Overlay closed - listening aborted');
|
||||
}
|
||||
}, [isVisible, isListening, addWebSpeechEventLog]);
|
||||
|
||||
// ShopperHouse API 응답 수신 시 overlay 닫기
|
||||
useEffect(() => {
|
||||
if (DEBUG_MODE) {
|
||||
@@ -603,6 +776,52 @@ const VoiceInputOverlay = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 🔄 WebSpeech 에러 재시작 함수
|
||||
const restartWebSpeech = useCallback(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInput] 🔄 Restarting WebSpeech after error');
|
||||
}
|
||||
|
||||
// 이벤트 로그 기록
|
||||
addWebSpeechEventLog('RESTART', 'WebSpeech restarted after error or manual action');
|
||||
|
||||
// 에러 상태 정리 (Redux)
|
||||
dispatch(clearSTTText());
|
||||
|
||||
// 모든 타이머 정리
|
||||
clearTimerRef(listeningTimerRef);
|
||||
clearTimerRef(silenceDetectionTimerRef);
|
||||
clearIntervalRef(countdownIntervalRef);
|
||||
|
||||
// Ref 초기화
|
||||
listeningTimerRef.current = null;
|
||||
silenceDetectionTimerRef.current = null;
|
||||
countdownIntervalRef.current = null;
|
||||
interimTextRef.current = '';
|
||||
|
||||
// 에러 메시지 정리
|
||||
setErrorMessage('');
|
||||
|
||||
// WebSpeech 이벤트 로그 정리
|
||||
setWebSpeechEventLogs([]);
|
||||
|
||||
// Input 필드 초기화
|
||||
if (onSearchChange) {
|
||||
onSearchChange({ value: '' });
|
||||
}
|
||||
|
||||
// PROMPT 모드로 복귀
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setVoiceInputMode(null);
|
||||
|
||||
// 약간의 지연 후 새로 시작 (안정성을 위해)
|
||||
setTimeout(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInput] ✅ Restart complete - ready for new input');
|
||||
}
|
||||
}, 300);
|
||||
}, [dispatch, onSearchChange, addWebSpeechEventLog]);
|
||||
|
||||
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion) => {
|
||||
@@ -754,10 +973,15 @@ const VoiceInputOverlay = ({
|
||||
case VOICE_MODES.NOTRECOGNIZED:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized'
|
||||
'❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized with error message'
|
||||
);
|
||||
}
|
||||
return <VoiceNotRecognized />;
|
||||
return (
|
||||
<VoiceNotRecognized
|
||||
prompt={errorMessage || '음성 인식에 문제가 발생했습니다.'}
|
||||
onRestart={restartWebSpeech}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.MODE_3:
|
||||
// 추후 MODE_3 컴포넌트 추가
|
||||
return <VoiceNotRecognized />;
|
||||
@@ -784,6 +1008,8 @@ const VoiceInputOverlay = ({
|
||||
interimText,
|
||||
sttResponseText,
|
||||
countdown,
|
||||
errorMessage,
|
||||
restartWebSpeech,
|
||||
]);
|
||||
|
||||
// 마이크 버튼 포커스 핸들러 (VUI)
|
||||
@@ -867,6 +1093,7 @@ const VoiceInputOverlay = ({
|
||||
setVoiceInputMode(null);
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setSttResponseText('');
|
||||
setErrorMessage('');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -1022,7 +1249,7 @@ const VoiceInputOverlay = ({
|
||||
fontSize: '18px',
|
||||
minWidth: '350px',
|
||||
lineHeight: '1.6',
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: '"LG Smart UI"',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -1219,6 +1446,11 @@ const VoiceInputOverlay = ({
|
||||
{/* 모드별 컨텐츠 */}
|
||||
<div className={css.modeContent}>{renderModeContent}</div>
|
||||
</OverlayContainer>
|
||||
|
||||
{/* WebSpeech 이벤트 전용 디버그 - 우측 하단에 표시 */}
|
||||
{DEBUG_MODE && webSpeechEventLogs.length > 0 && (
|
||||
<WebSpeechEventDebug logs={webSpeechEventLogs} />
|
||||
)}
|
||||
</div>
|
||||
</TFullPopup>
|
||||
);
|
||||
|
||||
@@ -316,3 +316,112 @@
|
||||
z-index: 1002;
|
||||
pointer-events: none; // 빈 공간 클릭 시 dimBackground로 이벤트 전달
|
||||
}
|
||||
|
||||
// WebSpeech 이벤트 전용 디버그 UI
|
||||
.webSpeechEventDebug {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
min-width: 350px;
|
||||
max-width: 400px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: "LG Smart UI";
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 10006; // 가장 높은 z-index
|
||||
pointer-events: auto; // 마우스 이벤트 허용
|
||||
}
|
||||
|
||||
.eventDebugHeader {
|
||||
font-weight: bold;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 2px solid #fff;
|
||||
padding-bottom: 8px;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
font-family: "LG Smart UI";
|
||||
}
|
||||
|
||||
.eventDebugLogs {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.eventLog {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.logTime {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
margin-bottom: 2px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.logEvent {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
font-family: "LG Smart UI";
|
||||
}
|
||||
|
||||
.logDetails {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 15px;
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.2);
|
||||
font-family: "LG Smart UI";
|
||||
}
|
||||
|
||||
// 이벤트 타입별 색상
|
||||
&.success .logEvent { color: #4CAF50; }
|
||||
&.success .logDetails { border-left-color: #4CAF50; }
|
||||
|
||||
&.error .logEvent { color: #F44336; }
|
||||
&.error .logDetails { border-left-color: #F44336; }
|
||||
|
||||
&.warning .logEvent { color: #FF9800; }
|
||||
&.warning .logDetails { border-left-color: #FF9800; }
|
||||
|
||||
&.info .logEvent { color: #2196F3; }
|
||||
&.info .logDetails { border-left-color: #2196F3; }
|
||||
}
|
||||
|
||||
.noEvents {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
font-family: "LG Smart UI";
|
||||
}
|
||||
|
||||
// 스크롤바 스타일링
|
||||
.eventDebugLogs::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.eventDebugLogs::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.eventDebugLogs::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// src/views/SearchPanel/VoiceInputOverlay/WebSpeechEventDebug.jsx
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import css from './VoiceInputOverlay.module.less';
|
||||
|
||||
/**
|
||||
* WebSpeechEventDebug - WebSpeech API 이벤트 전용 디버그 컴포넌트
|
||||
* DEBUG_MODE 또는 IS_DEBUG가 true일 때만 표시됨
|
||||
* WebSpeech API의 이벤트 발생 상황을 실시간으로 모니터링
|
||||
* @param {Array} logs - WebSpeech 이벤트 로그 배열
|
||||
*/
|
||||
const WebSpeechEventDebug = ({ logs }) => {
|
||||
// DEBUG_MODE 체크 (기존과 동일한 조건)
|
||||
const DEBUG_MODE = process.env.NODE_ENV === 'development' || true; // 항상 표시 (개발 중)
|
||||
|
||||
if (!DEBUG_MODE || !logs || logs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.webSpeechEventDebug}>
|
||||
<div className={css.eventDebugHeader}>🎤 WebSpeech Events</div>
|
||||
<div className={css.eventDebugLogs}>
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className={`${css.eventLog} ${css[log.type]}`}>
|
||||
<div className={css.logTime}>[{log.timestamp}]</div>
|
||||
<div className={css.logEvent}>{log.event}</div>
|
||||
{log.details && <div className={css.logDetails}>{log.details}</div>}
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && <div className={css.noEvents}>No WebSpeech events yet...</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
WebSpeechEventDebug.propTypes = {
|
||||
logs: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
timestamp: PropTypes.string.isRequired,
|
||||
event: PropTypes.string.isRequired,
|
||||
details: PropTypes.string,
|
||||
type: PropTypes.oneOf(['info', 'success', 'warning', 'error']).isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
WebSpeechEventDebug.defaultProps = {
|
||||
logs: [],
|
||||
};
|
||||
|
||||
export default WebSpeechEventDebug;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less
|
||||
@import "../../../style/CommonStyle.module.less";
|
||||
|
||||
// TFullPopup wrapper - TFullPopup의 기본 스타일을 override하지 않음
|
||||
|
||||
.voiceOverlayContainer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.dimBackground {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding-left: 120px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1002;
|
||||
pointer-events: none; // 빈 공간 클릭 시 dimBackground로 이벤트 전달
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 입력창과 마이크 버튼 영역 - SearchPanel.inputContainer와 동일 (210px 높이)
|
||||
.inputWrapper {
|
||||
width: 100%;
|
||||
padding-top: 60px; // 위쪽 패딩 증가 (55px → 60px) - 포커스 테두리 여유 공간
|
||||
padding-bottom: 60px; // 아래쪽 패딩 증가 (55px → 60px) - 포커스 테두리 여유 공간
|
||||
padding-left: 60px;
|
||||
padding-right: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1003;
|
||||
position: relative;
|
||||
pointer-events: all; // 입력 영역은 클릭 가능
|
||||
overflow: visible; // 포커스 테두리가 잘리지 않도록
|
||||
|
||||
.searchInputWrapper {
|
||||
height: 100px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
max-height: 100px;
|
||||
overflow: visible; // 포커스 테두리가 잘리지 않도록
|
||||
padding: 5px 0; // 위아래 여유 공간
|
||||
|
||||
> * {
|
||||
margin-right: 15px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inputBox {
|
||||
width: 880px;
|
||||
height: 100px !important;
|
||||
padding-left: 50px;
|
||||
padding-right: 40px;
|
||||
background: white;
|
||||
border-radius: 1000px;
|
||||
border: 5px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 1003;
|
||||
position: relative;
|
||||
|
||||
> div:first-child {
|
||||
margin: 0 !important;
|
||||
width: calc(100% - 121px) !important;
|
||||
height: 90px !important;
|
||||
padding: 20px 40px 20px 0px !important;
|
||||
border: none !important;
|
||||
background-color: #fff !important;
|
||||
|
||||
input {
|
||||
text-align: left;
|
||||
color: black;
|
||||
font-size: 42px;
|
||||
font-family: "LG Smart UI";
|
||||
font-weight: 700;
|
||||
line-height: 42px;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
border: 5px solid @PRIMARY_COLOR_RED;
|
||||
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.microphoneButton {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
z-index: 1003;
|
||||
|
||||
.microphoneCircle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
border-radius: 1000px;
|
||||
border: 5px solid #ccc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.microphoneIcon {
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.microphoneCircle {
|
||||
// border-color: @PRIMARY_COLOR_RED;
|
||||
border-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// active 상태 (음성 입력 모드 - 항상 빨간색)
|
||||
&.active {
|
||||
.microphoneCircle {
|
||||
background-color: @PRIMARY_COLOR_RED;
|
||||
border-color: @PRIMARY_COLOR_RED;
|
||||
box-shadow: 0 0 22px 0 rgba(229, 9, 20, 0.5);
|
||||
|
||||
.microphoneIcon {
|
||||
filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active.focused {
|
||||
.microphoneCircle {
|
||||
background-color: @PRIMARY_COLOR_RED;
|
||||
border-color: white;
|
||||
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// listening 상태 (배경 투명, 테두리 ripple 애니메이션)
|
||||
&.listening {
|
||||
.microphoneCircle {
|
||||
background-color: transparent;
|
||||
border-color: transparent; // 테두리 투명
|
||||
box-shadow: none;
|
||||
|
||||
.microphoneIcon {
|
||||
filter: brightness(0) invert(1); // 아이콘은 흰색 유지
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSpeech 마이크 버튼 (블루 계열)
|
||||
.microphoneButtonWebSpeech {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
z-index: 1003;
|
||||
|
||||
.microphoneCircle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
border-radius: 1000px;
|
||||
border: 5px solid #ccc;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.microphoneIcon {
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.microphoneCircle {
|
||||
border-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
// active 상태 (음성 입력 모드 - 블루 색상)
|
||||
&.active {
|
||||
.microphoneCircle {
|
||||
background-color: #4A90E2;
|
||||
border-color: #4A90E2;
|
||||
box-shadow: 0 0 22px 0 rgba(74, 144, 226, 0.5);
|
||||
|
||||
.microphoneIcon {
|
||||
filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active.focused {
|
||||
.microphoneCircle {
|
||||
background-color: #4A90E2;
|
||||
border-color: white;
|
||||
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// listening 상태 (배경 투명, 테두리 ripple 애니메이션)
|
||||
&.listening {
|
||||
.microphoneCircle {
|
||||
background-color: transparent;
|
||||
border-color: transparent; // 테두리 투명
|
||||
box-shadow: none;
|
||||
|
||||
.microphoneIcon {
|
||||
filter: brightness(0) invert(1); // 아이콘은 흰색 유지
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ripple 애니메이션 (원형 테두리가 점에서 시작해서 그려짐)
|
||||
.rippleSvg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rippleCircle {
|
||||
stroke-dasharray: 295.3; // 2 * PI * 47 (원의 둘레)
|
||||
stroke-dashoffset: 295.3; // 초기값: 완전히 숨김
|
||||
transform-origin: center;
|
||||
transform: rotate(-90deg); // 12시 방향에서 시작
|
||||
animation: drawCircle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// 15초 프로그레스 원 (countdown 기반)
|
||||
.rippleCircleProgress {
|
||||
stroke-dasharray: 295.3; // 2 * PI * 47 (원의 둘레)
|
||||
stroke-dashoffset: 295.3; // 초기값: 완전히 숨김
|
||||
transform-origin: center;
|
||||
transform: rotate(-90deg); // 12시 방향에서 시작
|
||||
transition: stroke-dashoffset 1s linear; // 부드러운 전환
|
||||
}
|
||||
|
||||
@keyframes drawCircle {
|
||||
0% {
|
||||
stroke-dashoffset: 295.3; // 점에서 시작
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0; // 원 완성 (계속 시계방향으로)
|
||||
}
|
||||
}
|
||||
|
||||
// 모드별 컨텐츠 영역 - 화면 중앙에 배치
|
||||
.modeContent {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-right: 120px;
|
||||
z-index: 1002;
|
||||
pointer-events: none; // 빈 공간 클릭 시 dimBackground로 이벤트 전달
|
||||
}
|
||||
Reference in New Issue
Block a user