Files
shoptime/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
junghoon86.park e93b379c51 [Voice_search]
- VoiceNotRecognized, VoiceNotRecognizedCircle
피그마에 맞춰 추가.
 - VoiceListening 스타일 수정
2025-10-16 12:30:48 +09:00

361 lines
11 KiB
JavaScript

// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
import React, {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import {
useDispatch,
useSelector,
} from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import micIcon from '../../../../assets/images/searchpanel/image-mic.png';
import { getShopperHouseSearch } from '../../../actions/searchActions';
import TFullPopup from '../../../components/TFullPopup/TFullPopup';
import TInput, {
ICONS,
KINDS,
} from '../../../components/TInput/TInput';
import VoiceListening from './modes/VoiceListening';
import VoiceNotRecognized from './modes/VoiceNotRecognized';
import VoiceNotRecognizedCircle from './modes/VoiceNotRecognizedCircle';
import VoicePromptScreen from './modes/VoicePromptScreen';
import css from './VoiceInputOverlay.module.less';
const OverlayContainer = SpotlightContainerDecorator(
{
enterTo: "default-element",
restrict: "self-only", // 포커스를 overlay 내부로만 제한
},
"div"
);
const SpottableMicButton = Spottable("div");
// Voice overlay 모드 상수
export const VOICE_MODES = {
PROMPT: "prompt", // Try saying 화면
LISTENING: "listening", // 듣는 중 화면
MODE_3: "mode3", // 추후 추가
MODE_4: "mode4", // 추후 추가
};
const OVERLAY_SPOTLIGHT_ID = "voice-input-overlay-container";
const INPUT_SPOTLIGHT_ID = "voice-overlay-input-box";
const MIC_SPOTLIGHT_ID = "voice-overlay-mic-button";
const VoiceInputOverlay = ({
isVisible,
onClose,
mode = VOICE_MODES.PROMPT,
suggestions = [],
searchQuery = "",
onSearchChange,
onSearchSubmit,
}) => {
const dispatch = useDispatch();
const lastFocusedElement = useRef(null);
const [inputFocus, setInputFocus] = useState(false);
// 내부 모드 상태 관리 (prompt -> listening -> close)
const [currentMode, setCurrentMode] = useState(mode);
// Redux에서 voice 상태 가져오기
const { isRegistered, lastSTTText, sttTimestamp } = useSelector(
(state) => state.voice
);
// STT 텍스트 수신 시 처리
useEffect(() => {
if (lastSTTText && sttTimestamp && isVisible) {
console.log(
"[VoiceInputOverlay] STT text received in overlay:",
lastSTTText
);
// 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
if (onSearchChange) {
onSearchChange({ value: lastSTTText });
}
// listening 모드로 전환 (시각적 피드백)
setCurrentMode(VOICE_MODES.LISTENING);
// 1초 후 자동 닫기 (선택사항)
setTimeout(() => {
onClose();
}, 1000);
}
}, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
useEffect(() => {
if (isVisible) {
// 현재 포커스된 요소 저장
lastFocusedElement.current = Spotlight.getCurrent();
// 모드 초기화 (항상 prompt 모드로 시작)
setCurrentMode(mode);
// Overlay 내부로 포커스 이동
setTimeout(() => {
Spotlight.focus(OVERLAY_SPOTLIGHT_ID);
}, 100);
} else {
// Overlay가 닫힐 때 원래 포커스 복원
if (lastFocusedElement.current) {
setTimeout(() => {
Spotlight.focus(lastFocusedElement.current);
}, 100);
}
}
}, [isVisible, mode]);
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트만 설정
const handleSuggestionClick = useCallback(
(suggestion) => {
console.log("[VoiceInputOverlay] Suggestion clicked:", suggestion);
// 따옴표 제거
const query = suggestion.replace(/^["']|["']$/g, "").trim();
// Input 창에 텍스트 설정
if (onSearchChange) {
onSearchChange({ value: query });
}
},
[onSearchChange]
);
// Input 창에서 API 호출 핸들러 (엔터키 또는 돋보기 아이콘 클릭)
const handleSearchSubmit = useCallback(() => {
console.log("[VoiceInputOverlay] Search submit:", searchQuery);
if (searchQuery && searchQuery.trim()) {
// ShopperHouse API 호출
dispatch(getShopperHouseSearch(searchQuery.trim()));
// VoiceInputOverlay는 SearchPanel과 다른 API를 사용하므로 onSearchSubmit 호출 안 함
// if (onSearchSubmit) {
// onSearchSubmit(searchQuery);
// }
}
}, [dispatch, searchQuery]);
// Input 창에서 엔터키 핸들러
const handleInputKeyDown = useCallback(
(e) => {
if (e.key === "Enter" || e.keyCode === 13) {
e.preventDefault();
handleSearchSubmit();
}
},
[handleSearchSubmit]
);
// 모드에 따른 컨텐츠 렌더링
const renderModeContent = () => {
switch (currentMode) {
case VOICE_MODES.PROMPT:
return (
<VoicePromptScreen
suggestions={suggestions}
onSuggestionClick={handleSuggestionClick}
/>
);
case VOICE_MODES.LISTENING:
return <VoiceListening />;
case VOICE_MODES.MODE_3:
// 추후 MODE_3 컴포넌트 추가
return <VoiceNotRecognized />;
case VOICE_MODES.MODE_4:
// 추후 MODE_4 컴포넌트 추가
return <VoiceNotRecognizedCircle />;
default:
return (
<VoicePromptScreen
suggestions={suggestions}
onSuggestionClick={handleSuggestionClick}
/>
);
}
};
// 입력창 포커스 핸들러
const handleInputFocus = useCallback(() => {
setInputFocus(true);
}, []);
const handleInputBlur = useCallback(() => {
setInputFocus(false);
}, []);
// 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
const handleMicClick = useCallback(
(e) => {
console.log(
"[VoiceInputOverlay] handleMicClick called, currentMode:",
currentMode
);
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
e.stopPropagation();
}
if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
e.nativeEvent.stopImmediatePropagation();
}
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> listening 모드로 전환
console.log("[VoiceInputOverlay] Switching to LISTENING mode");
setCurrentMode(VOICE_MODES.LISTENING);
// 이 시점에서 webOS Voice Framework가 자동으로 음성 인식 시작
// (이미 registerVoiceFramework()로 등록되어 있으므로)
} else if (currentMode === VOICE_MODES.LISTENING) {
// listening 모드에서 클릭 시 -> 종료
console.log("[VoiceInputOverlay] Closing from LISTENING mode");
onClose();
} else {
// 기타 모드에서는 바로 종료
console.log("[VoiceInputOverlay] Closing from other mode");
onClose();
}
},
[currentMode, onClose]
);
// dim 레이어 클릭 핸들러 (마이크 버튼과 분리)
const handleDimClick = useCallback(
(e) => {
console.log("[VoiceInputOverlay] dimBackground clicked");
onClose();
},
[onClose]
);
return (
<TFullPopup
open={isVisible}
onClose={onClose}
noAutoDismiss={true}
spotlightRestrict="self-only"
spotlightId={OVERLAY_SPOTLIGHT_ID}
noAnimation={false}
scrimType="transparent"
className={css.tFullPopupWrapper}
>
<div className={css.voiceOverlayContainer}>
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
<div className={css.dimBackground} onClick={handleDimClick} />
{/* Voice 등록 상태 표시 (디버깅용) */}
{process.env.NODE_ENV === "development" && (
<div
style={{
position: "absolute",
top: 10,
right: 10,
color: "#fff",
zIndex: 10000,
}}
>
Voice: {isRegistered ? "✓ Ready" : "✗ Not Ready"}
</div>
)}
{/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */}
<OverlayContainer
className={css.contentArea}
spotlightId={OVERLAY_SPOTLIGHT_ID}
spotlightDisabled={!isVisible}
>
{/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */}
<div
className={css.inputWrapper}
onClick={(e) => e.stopPropagation()}
>
<div className={css.searchInputWrapper}>
<TInput
className={css.inputBox}
kind={KINDS.withIcon}
icon={ICONS.search}
value={searchQuery}
onChange={onSearchChange}
onKeyDown={handleInputKeyDown}
onIconClick={handleSearchSubmit}
spotlightId={INPUT_SPOTLIGHT_ID}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING && css.listening
)}
onClick={handleMicClick}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleMicClick(e);
}
}}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img
src={micIcon}
alt="Microphone"
className={css.microphoneIcon}
/>
</div>
{currentMode === VOICE_MODES.LISTENING && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#C70850"
strokeWidth="6"
/>
</svg>
)}
</SpottableMicButton>
</div>
</div>
{/* 모드별 컨텐츠 */}
{renderModeContent()}
</OverlayContainer>
</div>
</TFullPopup>
);
};
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,
};
VoiceInputOverlay.defaultProps = {
mode: VOICE_MODES.PROMPT,
suggestions: [],
searchQuery: "",
onSearchChange: null,
onSearchSubmit: null,
};
export default VoiceInputOverlay;