[251022] fix: VoiceInputOverlay Bubble Search and 코드정리
🕐 커밋 시간: 2025. 10. 22. 13:07:25 📊 변경 통계: • 총 파일: 5개 • 추가: +163줄 • 삭제: -142줄 📝 수정된 파일: ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/v2/ShopNowButton.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceApiError.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceResponse.jsx 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/v2/ShopNowButton.jsx (javascript): ✅ Added: handleSpotlightUp() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript): ❌ Deleted: clearAllTimers() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceApiError.jsx (javascript): ✅ Added: getErrorMessage(), getErrorDetails() 🔄 Modified: Spottable() ❌ Deleted: getErrorMessage(), getErrorDetails() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx (javascript): 🔄 Modified: SpotlightContainerDecorator() ❌ Deleted: handleClick() 🔧 주요 변경 내용: • API 서비스 레이어 개선
This commit is contained in:
@@ -9,17 +9,18 @@ import css from './ShopNowButton.module.less';
|
||||
const SpottableDiv = Spottable('div');
|
||||
|
||||
export default function ShopNowButton({ onClick }) {
|
||||
const handleSpotlightUp = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
Spotlight.focus(SpotlightIds.PLAYER_BACK_BUTTON);
|
||||
};
|
||||
return (
|
||||
<div className={css.container}>
|
||||
<SpottableDiv
|
||||
className={css.shopNowButton}
|
||||
onClick={onClick}
|
||||
spotlightId="below-tab-shop-now-button"
|
||||
onSpotlightUp={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
Spotlight.focus(SpotlightIds.PLAYER_BACK_BUTTON);
|
||||
}}
|
||||
onSpotlightUp={handleSpotlightUp}
|
||||
>
|
||||
<span className={css.buttonText}>SHOP NOW</span>
|
||||
</SpottableDiv>
|
||||
|
||||
@@ -115,7 +115,6 @@ const VoiceInputOverlay = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
mode = VOICE_MODES.PROMPT,
|
||||
suggestions = [],
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
}) => {
|
||||
@@ -164,6 +163,8 @@ const VoiceInputOverlay = ({
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
// WebSpeech 이벤트 로그 저장 (디버그용)
|
||||
const [webSpeechEventLogs, setWebSpeechEventLogs] = useState([]);
|
||||
// Bubble 클릭으로 검색이 시작되었는지 추적하는 상태
|
||||
const [isBubbleClickSearch, setIsBubbleClickSearch] = useState(false);
|
||||
// 검색 기록 관리 (localStorage 기반, 최근 5개)
|
||||
const [searchHistory, setSearchHistory] = useState(() => {
|
||||
const history = readLocalStorage(SEARCH_HISTORY_KEY, DEFAULT_SUGGESTIONS);
|
||||
@@ -241,28 +242,7 @@ const VoiceInputOverlay = ({
|
||||
);
|
||||
|
||||
// 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 { lastSTTText, sttTimestamp, webSpeech = {} } = useSelector((state) => state.voice);
|
||||
|
||||
// 이벤트 타입별 색상 분류
|
||||
const getWebSpeechEventType = useCallback((event) => {
|
||||
@@ -280,6 +260,30 @@ const VoiceInputOverlay = ({
|
||||
return types[event] || 'info';
|
||||
}, []);
|
||||
|
||||
// 🎤 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개만 유지
|
||||
});
|
||||
},
|
||||
[getWebSpeechEventType]
|
||||
);
|
||||
|
||||
// Redux에서 shopperHouse 검색 결과 및 에러 가져오기 (simplified ref usage)
|
||||
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
|
||||
const shopperHouseSearchId = useSelector((state) => state.search.shopperHouseSearchId); // 2차 발화용 searchId
|
||||
@@ -312,10 +316,18 @@ const VoiceInputOverlay = ({
|
||||
|
||||
// 🚨 WebSpeech 에러 감시 및 처리 (Phase 1)
|
||||
useEffect(() => {
|
||||
if (webSpeech.error) {
|
||||
// Bubble 클릭으로 검색이 시작된 경우 WebSpeech 오류를 우회
|
||||
if (isBubbleClickSearch) {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInput] 🔇 WebSpeech 오류 우회 (bubble click search)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (webSpeech && 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] ├─ message:', webSpeech.message || 'No message');
|
||||
console.error('[VoiceInput] ├─ current mode:', currentMode);
|
||||
console.error('[VoiceInput] └─ is listening:', isListening);
|
||||
|
||||
@@ -373,12 +385,14 @@ const VoiceInputOverlay = ({
|
||||
}
|
||||
}, [
|
||||
webSpeech.error,
|
||||
webSpeech.message,
|
||||
currentMode,
|
||||
isListening,
|
||||
isVisible,
|
||||
stopListening,
|
||||
restartWebSpeech,
|
||||
addWebSpeechEventLog,
|
||||
isBubbleClickSearch,
|
||||
]);
|
||||
|
||||
// 🎤 WebSpeech 이벤트 감지 (전용 디버그용)
|
||||
@@ -739,7 +753,7 @@ const VoiceInputOverlay = ({
|
||||
dispatch(clearShopperHouseData());
|
||||
dispatch(clearSTTText());
|
||||
};
|
||||
}, []); // 빈 dependency - 언마운트 시에만 실행
|
||||
}, [dispatch]); // 언마운트 시에만 실행
|
||||
|
||||
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
|
||||
useEffect(() => {
|
||||
@@ -841,6 +855,7 @@ const VoiceInputOverlay = ({
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setVoiceInputMode(null);
|
||||
setSttResponseText('');
|
||||
setIsBubbleClickSearch(false); // bubble 클릭 상태 초기화
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInput] ✅ API error restart complete - ready for new input');
|
||||
@@ -916,6 +931,7 @@ const VoiceInputOverlay = ({
|
||||
// PROMPT 모드로 복귀
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setVoiceInputMode(null);
|
||||
setIsBubbleClickSearch(false); // bubble 클릭 상태 초기화
|
||||
|
||||
// 약간의 지연 후 새로 시작 (안정성을 위해)
|
||||
setTimeout(() => {
|
||||
@@ -925,240 +941,6 @@ const VoiceInputOverlay = ({
|
||||
}, 300);
|
||||
}, [dispatch, onSearchChange, addWebSpeechEventLog]);
|
||||
|
||||
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출 + response 모드 전환
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion) => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('💡 [DEBUG] handleSuggestionClick called with:', suggestion);
|
||||
}
|
||||
|
||||
// 따옴표 제거
|
||||
const query = suggestion.replace(/^["']|["']$/g, '').trim();
|
||||
|
||||
// Input 창에 텍스트 설정 (유지)
|
||||
if (onSearchChange) {
|
||||
onSearchChange({ value: query });
|
||||
}
|
||||
|
||||
// ✨ 검색 기록에 추가
|
||||
addToSearchHistory(query);
|
||||
|
||||
// ✨ RESPONSE 모드로 전환을 위한 텍스트 설정
|
||||
setSttResponseText(query);
|
||||
|
||||
// ✨ RESPONSE 모드로 직접 전환
|
||||
setCurrentMode(VOICE_MODES.RESPONSE);
|
||||
setVoiceInputMode(null);
|
||||
|
||||
// ✨ ShopperHouse API 자동 호출 (searchId 포함)
|
||||
if (query && query.length >= 3) {
|
||||
// ✅ Ref에서 최신 searchId 읽기 (이전 검색이 있는 경우 2차 발화 처리)
|
||||
const currentSearchId = shopperHouseSearchIdRef.current;
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔍 [DEBUG] Calling ShopperHouse API from bubble click');
|
||||
console.log('[VoiceInput] ├─ query:', query);
|
||||
console.log('[VoiceInput] └─ searchId:', currentSearchId || '(없음 - 첫 번째 발화)');
|
||||
}
|
||||
try {
|
||||
dispatch(getShopperHouseSearch(query, currentSearchId));
|
||||
} catch (error) {
|
||||
console.error('[VoiceInput] ❌ API 호출 실패:', error);
|
||||
// 에러 발생 시 PROMPT 모드로 복귀
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setVoiceInputMode(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSearchChange, dispatch, addToSearchHistory]
|
||||
);
|
||||
|
||||
// Input 창에서 API 호출 핸들러 (돋보기 아이콘 클릭 시에만)
|
||||
const handleSearchSubmit = useCallback(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInputOverlay.v2] Search submit:', searchQuery);
|
||||
}
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
// ShopperHouse API 호출
|
||||
dispatch(getShopperHouseSearch(searchQuery.trim()));
|
||||
|
||||
// Input 내용 비우기
|
||||
if (onSearchChange) {
|
||||
onSearchChange({ value: '' });
|
||||
}
|
||||
|
||||
// Clear existing timer before setting new one
|
||||
clearTimerRef(searchSubmitFocusTimerRef);
|
||||
|
||||
// API 호출 후 Input 박스로 포커스 이동
|
||||
searchSubmitFocusTimerRef.current = setTimeout(() => {
|
||||
Spotlight.focus(INPUT_SPOTLIGHT_ID);
|
||||
}, 100);
|
||||
|
||||
// VoiceInputOverlay는 SearchPanel과 다른 API를 사용하므로 onSearchSubmit 호출 안 함
|
||||
// if (onSearchSubmit) {
|
||||
// onSearchSubmit(searchQuery);
|
||||
// }
|
||||
}
|
||||
}, [dispatch, searchQuery, onSearchChange]);
|
||||
|
||||
// Input 창에서 엔터키 핸들러 (API 호출하지 않음)
|
||||
const handleInputKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
// Enter 키로는 API 호출하지 않음
|
||||
// 돋보기 아이콘 클릭/Enter로만 API 호출
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Input 모드 변경 핸들러 - Input 모드로 전환되면 VoiceInputOverlay 닫기
|
||||
const handleInputModeChange = useCallback(
|
||||
(mode) => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInputOverlay] TInput 모드 변경:', mode);
|
||||
}
|
||||
|
||||
if (mode === 'input') {
|
||||
// Input 모드로 전환되면 Overlay 닫기
|
||||
// SearchPanel의 TInput으로 자연스럽게 전환됨
|
||||
if (DEBUG_MODE) {
|
||||
console.log('⌨️ [DEBUG] Input 모드로 전환됨 - VoiceInputOverlay 닫고 SearchPanel로 이동');
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
|
||||
// ⛔ TALK AGAIN 버튼 제거됨 - 더 이상 사용하지 않음
|
||||
// const handleTalkAgain = useCallback(() => { ... }, []);
|
||||
|
||||
// 모드에 따른 컨텐츠 렌더링 - Memoized
|
||||
const renderModeContent = useMemo(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'🎬 [DEBUG][VoiceInputOverlay] renderModeContent called',
|
||||
'| currentMode:',
|
||||
currentMode,
|
||||
'| voiceInputMode:',
|
||||
voiceInputMode,
|
||||
'| isListening:',
|
||||
isListening
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentMode) {
|
||||
case VOICE_MODES.PROMPT:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'✅ [DEBUG][VoiceInputOverlay] MODE = PROMPT | Rendering VoicePromptScreen with',
|
||||
searchHistory.length,
|
||||
'suggestions'
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VoicePromptScreen
|
||||
suggestions={searchHistory}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.LISTENING:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'🎤 [DEBUG][VoiceInputOverlay] MODE = LISTENING | Rendering VoiceListening (15초 타이머)'
|
||||
);
|
||||
}
|
||||
return <VoiceListening interimText={interimText} countdown={countdown} />;
|
||||
case VOICE_MODES.RESPONSE:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'💬 [DEBUG][VoiceInputOverlay] MODE = RESPONSE | Rendering VoiceResponse with text:',
|
||||
sttResponseText,
|
||||
'isLoading: true (항상 로딩 애니메이션 표시)'
|
||||
);
|
||||
}
|
||||
// ✅ RESPONSE 모드에서는 항상 로딩 애니메이션 표시
|
||||
// shopperHouseData는 overlay 닫기에만 사용 (searchId 유지 필요)
|
||||
return (
|
||||
<VoiceResponse
|
||||
responseText={sttResponseText}
|
||||
isLoading={true}
|
||||
query={sttResponseText}
|
||||
searchId={shopperHouseSearchId || ''}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.NOINIT:
|
||||
if (DEBUG_MODE) {
|
||||
console.log('⚠️ [DEBUG][VoiceInputOverlay] MODE = NOINIT | Rendering VoiceNotRecognized');
|
||||
}
|
||||
return <VoiceNotRecognized prompt={NOINIT_ERROR_MESSAGE} />;
|
||||
case VOICE_MODES.NOTRECOGNIZED:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized with error message'
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VoiceNotRecognized
|
||||
prompt={errorMessage || '음성 인식에 문제가 발생했습니다.'}
|
||||
onRestart={restartWebSpeech}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.APIERROR:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'💥 [DEBUG][VoiceInputOverlay] MODE = APIERROR | Rendering VoiceApiError with error details',
|
||||
shopperHouseError
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VoiceApiError
|
||||
error={shopperHouseError}
|
||||
onRetry={handleApiErrorRetry}
|
||||
onRestart={handleApiErrorRestart}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.MODE_3:
|
||||
// 추후 MODE_3 컴포넌트 추가
|
||||
return <VoiceNotRecognized />;
|
||||
case VOICE_MODES.MODE_4:
|
||||
// 추후 MODE_4 컴포넌트 추가
|
||||
return <VoiceNotRecognizedCircle />;
|
||||
default:
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔄 [DEBUG][VoiceInputOverlay] MODE = DEFAULT | Rendering VoicePromptScreen');
|
||||
}
|
||||
return (
|
||||
<VoicePromptScreen
|
||||
suggestions={searchHistory}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
currentMode,
|
||||
voiceInputMode,
|
||||
isListening,
|
||||
searchHistory,
|
||||
handleSuggestionClick,
|
||||
interimText,
|
||||
sttResponseText,
|
||||
countdown,
|
||||
errorMessage,
|
||||
restartWebSpeech,
|
||||
shopperHouseError,
|
||||
handleApiErrorRetry,
|
||||
handleApiErrorRestart,
|
||||
]);
|
||||
|
||||
// 마이크 버튼 포커스 핸들러 (VUI)
|
||||
const handleMicFocus = useCallback(() => {
|
||||
setMicFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleMicBlur = useCallback(() => {
|
||||
setMicFocused(false);
|
||||
}, []);
|
||||
|
||||
// 🎤 음성 입력 최종 처리 함수 (15초 타이머 & 3초 silence detection 공통 사용)
|
||||
const processFinalVoiceInput = useCallback(
|
||||
(source) => {
|
||||
@@ -1232,9 +1014,254 @@ const VoiceInputOverlay = ({
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setSttResponseText('');
|
||||
setErrorMessage('');
|
||||
setIsBubbleClickSearch(false); // bubble 클릭 상태 초기화
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출 + response 모드 전환
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion) => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('💡 [DEBUG] handleSuggestionClick called with:', suggestion);
|
||||
}
|
||||
|
||||
// Bubble 클릭으로 검색 시작 상태 설정
|
||||
setIsBubbleClickSearch(true);
|
||||
|
||||
// 따옴표 제거
|
||||
const query = suggestion.replace(/^["']|["']$/g, '').trim();
|
||||
|
||||
// Input 창에 텍스트 설정 (유지)
|
||||
if (onSearchChange) {
|
||||
onSearchChange({ value: query });
|
||||
}
|
||||
|
||||
// ✨ 검색 기록에 추가
|
||||
addToSearchHistory(query);
|
||||
|
||||
// ✨ RESPONSE 모드로 전환을 위한 텍스트 설정
|
||||
setSttResponseText(query);
|
||||
|
||||
// ✨ RESPONSE 모드로 직접 전환
|
||||
setCurrentMode(VOICE_MODES.RESPONSE);
|
||||
setVoiceInputMode(null);
|
||||
|
||||
// ✨ ShopperHouse API 자동 호출 (searchId 포함)
|
||||
if (query && query.length >= 3) {
|
||||
// ✅ Ref에서 최신 searchId 읽기 (이전 검색이 있는 경우 2차 발화 처리)
|
||||
const currentSearchId = shopperHouseSearchIdRef.current;
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔍 [DEBUG] Calling ShopperHouse API from bubble click');
|
||||
console.log('[VoiceInput] ├─ query:', query);
|
||||
console.log('[VoiceInput] └─ searchId:', currentSearchId || '(없음 - 첫 번째 발화)');
|
||||
}
|
||||
try {
|
||||
dispatch(getShopperHouseSearch(query, currentSearchId));
|
||||
} catch (error) {
|
||||
console.error('[VoiceInput] ❌ API 호출 실패:', error);
|
||||
// 에러 발생 시 PROMPT 모드로 복귀
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setVoiceInputMode(null);
|
||||
setIsBubbleClickSearch(false); // 상태 초기화
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSearchChange, dispatch, addToSearchHistory]
|
||||
);
|
||||
|
||||
// Input 창에서 API 호출 핸들러 (돋보기 아이콘 클릭 시에만)
|
||||
const handleSearchSubmit = useCallback(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInputOverlay.v2] Search submit:', searchQuery);
|
||||
}
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
// ShopperHouse API 호출
|
||||
dispatch(getShopperHouseSearch(searchQuery.trim()));
|
||||
|
||||
// Input 내용 비우기
|
||||
if (onSearchChange) {
|
||||
onSearchChange({ value: '' });
|
||||
}
|
||||
|
||||
// Clear existing timer before setting new one
|
||||
clearTimerRef(searchSubmitFocusTimerRef);
|
||||
|
||||
// API 호출 후 Input 박스로 포커스 이동
|
||||
searchSubmitFocusTimerRef.current = setTimeout(() => {
|
||||
Spotlight.focus(INPUT_SPOTLIGHT_ID);
|
||||
}, 100);
|
||||
|
||||
// VoiceInputOverlay는 SearchPanel과 다른 API를 사용하므로 onSearchSubmit 호출 안 함
|
||||
// if (onSearchSubmit) {
|
||||
// onSearchSubmit(searchQuery);
|
||||
// }
|
||||
}
|
||||
}, [dispatch, searchQuery, onSearchChange]);
|
||||
|
||||
// Input 창에서 엔터키 핸들러 (API 호출하지 않음)
|
||||
const handleInputKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
// Enter 키로는 API 호출하지 않음
|
||||
// 돋보기 아이콘 클릭/Enter로만 API 호출
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Input 모드 변경 핸들러 - Input 모드로 전환되면 VoiceInputOverlay 닫기
|
||||
const handleInputModeChange = useCallback(
|
||||
(inputMode) => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInputOverlay] TInput 모드 변경:', inputMode);
|
||||
}
|
||||
|
||||
if (inputMode === 'input') {
|
||||
// Input 모드로 전환되면 Overlay 닫기
|
||||
// SearchPanel의 TInput으로 자연스럽게 전환됨
|
||||
if (DEBUG_MODE) {
|
||||
console.log('⌨️ [DEBUG] Input 모드로 전환됨 - VoiceInputOverlay 닫고 SearchPanel로 이동');
|
||||
}
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
|
||||
// ⛔ TALK AGAIN 버튼 제거됨 - 더 이상 사용하지 않음
|
||||
// const handleTalkAgain = useCallback(() => { ... }, []);
|
||||
|
||||
// 모드에 따른 컨텐츠 렌더링 - Memoized
|
||||
const renderModeContent = useMemo(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'🎬 [DEBUG][VoiceInputOverlay] renderModeContent called',
|
||||
'| currentMode:',
|
||||
currentMode,
|
||||
'| voiceInputMode:',
|
||||
voiceInputMode,
|
||||
'| isListening:',
|
||||
isListening
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentMode) {
|
||||
case VOICE_MODES.PROMPT:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'✅ [DEBUG][VoiceInputOverlay] MODE = PROMPT | Rendering VoicePromptScreen with',
|
||||
searchHistory.length,
|
||||
'suggestions'
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VoicePromptScreen
|
||||
suggestions={searchHistory}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.LISTENING:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'🎤 [DEBUG][VoiceInputOverlay] MODE = LISTENING | Rendering VoiceListening (15초 타이머)'
|
||||
);
|
||||
}
|
||||
return <VoiceListening interimText={interimText} countdown={countdown} />;
|
||||
case VOICE_MODES.RESPONSE:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'💬 [DEBUG][VoiceInputOverlay] MODE = RESPONSE | Rendering VoiceResponse with text:',
|
||||
sttResponseText,
|
||||
'isLoading: true (항상 로딩 애니메이션 표시)'
|
||||
);
|
||||
}
|
||||
// ✅ RESPONSE 모드에서는 항상 로딩 애니메이션 표시
|
||||
// shopperHouseData는 overlay 닫기에만 사용 (searchId 유지 필요)
|
||||
return (
|
||||
<VoiceResponse
|
||||
responseText={sttResponseText}
|
||||
isLoading
|
||||
query={sttResponseText}
|
||||
searchId={shopperHouseSearchId || ''}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.NOINIT:
|
||||
if (DEBUG_MODE) {
|
||||
console.log('⚠️ [DEBUG][VoiceInputOverlay] MODE = NOINIT | Rendering VoiceNotRecognized');
|
||||
}
|
||||
return <VoiceNotRecognized prompt={NOINIT_ERROR_MESSAGE} />;
|
||||
case VOICE_MODES.NOTRECOGNIZED:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized with error message'
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VoiceNotRecognized
|
||||
prompt={errorMessage || '음성 인식에 문제가 발생했습니다.'}
|
||||
onRestart={restartWebSpeech}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.APIERROR:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'💥 [DEBUG][VoiceInputOverlay] MODE = APIERROR | Rendering VoiceApiError with error details',
|
||||
shopperHouseError
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VoiceApiError
|
||||
error={shopperHouseError}
|
||||
onRetry={handleApiErrorRetry}
|
||||
onRestart={handleApiErrorRestart}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.MODE_3:
|
||||
// 추후 MODE_3 컴포넌트 추가
|
||||
return <VoiceNotRecognized />;
|
||||
case VOICE_MODES.MODE_4:
|
||||
// 추후 MODE_4 컴포넌트 추가
|
||||
return <VoiceNotRecognizedCircle />;
|
||||
default:
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔄 [DEBUG][VoiceInputOverlay] MODE = DEFAULT | Rendering VoicePromptScreen');
|
||||
}
|
||||
return (
|
||||
<VoicePromptScreen
|
||||
suggestions={searchHistory}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
currentMode,
|
||||
voiceInputMode,
|
||||
isListening,
|
||||
searchHistory,
|
||||
handleSuggestionClick,
|
||||
interimText,
|
||||
sttResponseText,
|
||||
countdown,
|
||||
errorMessage,
|
||||
restartWebSpeech,
|
||||
shopperHouseError,
|
||||
shopperHouseSearchId,
|
||||
handleApiErrorRetry,
|
||||
handleApiErrorRestart,
|
||||
]);
|
||||
|
||||
// 마이크 버튼 포커스 핸들러 (VUI)
|
||||
const handleMicFocus = useCallback(() => {
|
||||
setMicFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleMicBlur = useCallback(() => {
|
||||
setMicFocused(false);
|
||||
}, []);
|
||||
|
||||
// Input wrapper click handler to prevent event propagation
|
||||
const handleInputWrapperClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
// WebSpeech 마이크 버튼 클릭 핸들러
|
||||
const handleWebSpeechMicClick = useCallback(
|
||||
(e) => {
|
||||
@@ -1290,15 +1317,7 @@ const VoiceInputOverlay = ({
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[
|
||||
currentMode,
|
||||
handleClose,
|
||||
startListening,
|
||||
stopListening,
|
||||
onSearchChange,
|
||||
dispatch,
|
||||
processFinalVoiceInput,
|
||||
]
|
||||
[currentMode, handleClose, startListening, onSearchChange, dispatch, processFinalVoiceInput]
|
||||
);
|
||||
|
||||
// 마이크 버튼 키다운 핸들러
|
||||
@@ -1431,13 +1450,13 @@ const VoiceInputOverlay = ({
|
||||
<div>
|
||||
Interim:{' '}
|
||||
<span style={{ color: '#9C27B0', wordBreak: 'break-all' }}>
|
||||
"{interimText || 'N/A'}"
|
||||
"{interimText || 'N/A'}"
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Ref Text:{' '}
|
||||
<span style={{ color: '#9C27B0', wordBreak: 'break-all' }}>
|
||||
"{interimTextRef.current || 'N/A'}"
|
||||
"{interimTextRef.current || 'N/A'}"
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1449,7 +1468,7 @@ const VoiceInputOverlay = ({
|
||||
<div>
|
||||
STT Result:{' '}
|
||||
<span style={{ color: '#607D8B', wordBreak: 'break-all' }}>
|
||||
"{lastSTTText || 'None'}"
|
||||
"{lastSTTText || 'None'}"
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1515,7 +1534,7 @@ const VoiceInputOverlay = ({
|
||||
spotlightDisabled={!isVisible}
|
||||
>
|
||||
{/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */}
|
||||
<div className={css.inputWrapper} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={css.inputWrapper} onClick={handleInputWrapperClick}>
|
||||
<div className={css.searchInputWrapper}>
|
||||
<TInput
|
||||
className={css.inputBox}
|
||||
@@ -1526,8 +1545,8 @@ const VoiceInputOverlay = ({
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onIconClick={handleSearchSubmit}
|
||||
spotlightId={INPUT_SPOTLIGHT_ID}
|
||||
displayMode={true}
|
||||
allowModeToggle={true}
|
||||
displayMode
|
||||
allowModeToggle
|
||||
onModeChange={handleInputModeChange}
|
||||
placeholder="Search"
|
||||
/>
|
||||
@@ -1596,7 +1615,6 @@ VoiceInputOverlay.propTypes = {
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
mode: PropTypes.oneOf(Object.values(VOICE_MODES)),
|
||||
suggestions: PropTypes.arrayOf(PropTypes.string),
|
||||
searchQuery: PropTypes.string,
|
||||
onSearchChange: PropTypes.func,
|
||||
onSearchSubmit: PropTypes.func,
|
||||
@@ -1604,7 +1622,6 @@ VoiceInputOverlay.propTypes = {
|
||||
|
||||
VoiceInputOverlay.defaultProps = {
|
||||
mode: VOICE_MODES.PROMPT,
|
||||
suggestions: [],
|
||||
searchQuery: '',
|
||||
onSearchChange: null,
|
||||
onSearchSubmit: null,
|
||||
|
||||
@@ -9,53 +9,53 @@ const SpottableButton = Spottable('div');
|
||||
|
||||
const VoiceApiError = ({ error = null, onRestart = null, onRetry = null }) => {
|
||||
// 에러 객체에서 메시지 추출
|
||||
const getErrorMessage = (error) => {
|
||||
if (!error) return 'An unknown error occurred.';
|
||||
const getErrorMessage = (err) => {
|
||||
if (!err) return 'An unknown error occurred.';
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
if (typeof err === 'string') {
|
||||
return err;
|
||||
}
|
||||
|
||||
// API 에러 객체 구조에 따라 메시지 추출
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
if (err.message) {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
if (error.data && error.data.message) {
|
||||
return error.data.message;
|
||||
if (err.data && err.data.message) {
|
||||
return err.data.message;
|
||||
}
|
||||
|
||||
if (error.response && error.response.data && error.response.data.message) {
|
||||
return error.response.data.message;
|
||||
if (err.response && err.response.data && err.response.data.message) {
|
||||
return err.response.data.message;
|
||||
}
|
||||
|
||||
if (error.status) {
|
||||
return `API Error: ${error.status} - ${error.statusText || 'Unknown error'}`;
|
||||
if (err.status) {
|
||||
return `API Error: ${err.status} - ${err.statusText || 'Unknown error'}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(error);
|
||||
return JSON.stringify(err);
|
||||
};
|
||||
|
||||
// 에러 상세 정보 추출
|
||||
const getErrorDetails = (error) => {
|
||||
if (!error) return null;
|
||||
const getErrorDetails = (err) => {
|
||||
if (!err) return null;
|
||||
|
||||
const details = [];
|
||||
|
||||
if (error.status) {
|
||||
details.push(`Status: ${error.status}`);
|
||||
if (err.status) {
|
||||
details.push(`Status: ${err.status}`);
|
||||
}
|
||||
|
||||
if (error.code) {
|
||||
details.push(`Code: ${error.code}`);
|
||||
if (err.code) {
|
||||
details.push(`Code: ${err.code}`);
|
||||
}
|
||||
|
||||
if (error.timestamp) {
|
||||
details.push(`Time: ${new Date(error.timestamp).toLocaleString()}`);
|
||||
if (err.timestamp) {
|
||||
details.push(`Time: ${new Date(err.timestamp).toLocaleString()}`);
|
||||
}
|
||||
|
||||
if (error.endpoint) {
|
||||
details.push(`Endpoint: ${error.endpoint}`);
|
||||
if (err.endpoint) {
|
||||
details.push(`Endpoint: ${err.endpoint}`);
|
||||
}
|
||||
|
||||
return details.length > 0 ? details : null;
|
||||
|
||||
@@ -34,12 +34,11 @@ const VoicePromptScreen = ({ title = 'Try saying', suggestions = [], onSuggestio
|
||||
<div className={css.title}>{title}</div>
|
||||
<div className={css.suggestionsContainer}>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const handleClick = () => handleBubbleClick(suggestion);
|
||||
return (
|
||||
<SpottableBubble
|
||||
key={index}
|
||||
className={css.bubbleMessage}
|
||||
onClick={handleClick}
|
||||
onClick={() => handleBubbleClick(suggestion)}
|
||||
spotlightId={`voice-bubble-${index}`}
|
||||
>
|
||||
<div className={css.bubbleText}>{suggestion}</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import Spottable from '@enact/spotlight/Spottable';
|
||||
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
|
||||
import css from './VoiceResponse.module.less';
|
||||
|
||||
const SpottableBubble = Spottable('div');
|
||||
// const SpottableBubble = Spottable('div');
|
||||
|
||||
const ResponseContainer = SpotlightContainerDecorator(
|
||||
{
|
||||
@@ -46,7 +46,7 @@ const VoiceResponse = ({ responseText = '', isLoading = true, query = '', search
|
||||
return result;
|
||||
};
|
||||
|
||||
const displayText = capitalizeSentences(responseText);
|
||||
// const displayText = capitalizeSentences(responseText);
|
||||
|
||||
// 타이핑 애니메이션 효과 (반복)
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user