361 lines
11 KiB
JavaScript
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;
|