[Voice_search]

- VoiceNotRecognized, VoiceNotRecognizedCircle
피그마에 맞춰 추가.
 - VoiceListening 스타일 수정
This commit is contained in:
junghoon86.park
2025-10-16 12:30:48 +09:00
parent e729a8ee26
commit e93b379c51
7 changed files with 244 additions and 53 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,48 +1,64 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
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 SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import { useDispatch, useSelector } from 'react-redux';
import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput';
import TFullPopup from '../../../components/TFullPopup/TFullPopup';
import micIcon from '../../../../assets/images/searchpanel/image-mic.png';
import { getShopperHouseSearch } from '../../../actions/searchActions';
import css from './VoiceInputOverlay.module.less';
import VoicePromptScreen from './modes/VoicePromptScreen';
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 내부로만 제한
enterTo: "default-element",
restrict: "self-only", // 포커스를 overlay 내부로만 제한
},
'div'
"div"
);
const SpottableMicButton = Spottable('div');
const SpottableMicButton = Spottable("div");
// Voice overlay 모드 상수
export const VOICE_MODES = {
PROMPT: 'prompt', // Try saying 화면
LISTENING: 'listening', // 듣는 중 화면
MODE_3: 'mode3', // 추후 추가
MODE_4: 'mode4', // 추후 추가
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 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 = '',
searchQuery = "",
onSearchChange,
onSearchSubmit,
}) => {
@@ -53,12 +69,17 @@ const VoiceInputOverlay = ({
const [currentMode, setCurrentMode] = useState(mode);
// Redux에서 voice 상태 가져오기
const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
const { isRegistered, lastSTTText, sttTimestamp } = useSelector(
(state) => state.voice
);
// STT 텍스트 수신 시 처리
useEffect(() => {
if (lastSTTText && sttTimestamp && isVisible) {
console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);
console.log(
"[VoiceInputOverlay] STT text received in overlay:",
lastSTTText
);
// 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
if (onSearchChange) {
@@ -101,9 +122,9 @@ const VoiceInputOverlay = ({
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트만 설정
const handleSuggestionClick = useCallback(
(suggestion) => {
console.log('[VoiceInputOverlay] Suggestion clicked:', suggestion);
console.log("[VoiceInputOverlay] Suggestion clicked:", suggestion);
// 따옴표 제거
const query = suggestion.replace(/^["']|["']$/g, '').trim();
const query = suggestion.replace(/^["']|["']$/g, "").trim();
// Input 창에 텍스트 설정
if (onSearchChange) {
onSearchChange({ value: query });
@@ -114,7 +135,7 @@ const VoiceInputOverlay = ({
// Input 창에서 API 호출 핸들러 (엔터키 또는 돋보기 아이콘 클릭)
const handleSearchSubmit = useCallback(() => {
console.log('[VoiceInputOverlay] Search submit:', searchQuery);
console.log("[VoiceInputOverlay] Search submit:", searchQuery);
if (searchQuery && searchQuery.trim()) {
// ShopperHouse API 호출
dispatch(getShopperHouseSearch(searchQuery.trim()));
@@ -129,7 +150,7 @@ const VoiceInputOverlay = ({
// Input 창에서 엔터키 핸들러
const handleInputKeyDown = useCallback(
(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
if (e.key === "Enter" || e.keyCode === 13) {
e.preventDefault();
handleSearchSubmit();
}
@@ -142,19 +163,25 @@ const VoiceInputOverlay = ({
switch (currentMode) {
case VOICE_MODES.PROMPT:
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
<VoicePromptScreen
suggestions={suggestions}
onSuggestionClick={handleSuggestionClick}
/>
);
case VOICE_MODES.LISTENING:
return <VoiceListening />;
case VOICE_MODES.MODE_3:
// 추후 MODE_3 컴포넌트 추가
return <div>Mode 3 (Coming soon)</div>;
return <VoiceNotRecognized />;
case VOICE_MODES.MODE_4:
// 추후 MODE_4 컴포넌트 추가
return <div>Mode 4 (Coming soon)</div>;
return <VoiceNotRecognizedCircle />;
default:
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
<VoicePromptScreen
suggestions={suggestions}
onSuggestionClick={handleSuggestionClick}
/>
);
}
};
@@ -171,7 +198,10 @@ const VoiceInputOverlay = ({
// 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
const handleMicClick = useCallback(
(e) => {
console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode);
console.log(
"[VoiceInputOverlay] handleMicClick called, currentMode:",
currentMode
);
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
@@ -183,17 +213,17 @@ const VoiceInputOverlay = ({
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> listening 모드로 전환
console.log('[VoiceInputOverlay] Switching to LISTENING mode');
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');
console.log("[VoiceInputOverlay] Closing from LISTENING mode");
onClose();
} else {
// 기타 모드에서는 바로 종료
console.log('[VoiceInputOverlay] Closing from other mode');
console.log("[VoiceInputOverlay] Closing from other mode");
onClose();
}
},
@@ -203,7 +233,7 @@ const VoiceInputOverlay = ({
// dim 레이어 클릭 핸들러 (마이크 버튼과 분리)
const handleDimClick = useCallback(
(e) => {
console.log('[VoiceInputOverlay] dimBackground clicked');
console.log("[VoiceInputOverlay] dimBackground clicked");
onClose();
},
[onClose]
@@ -225,9 +255,17 @@ const VoiceInputOverlay = ({
<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'}
{process.env.NODE_ENV === "development" && (
<div
style={{
position: "absolute",
top: 10,
right: 10,
color: "#fff",
zIndex: 10000,
}}
>
Voice: {isRegistered ? "✓ Ready" : "✗ Not Ready"}
</div>
)}
@@ -238,7 +276,10 @@ const VoiceInputOverlay = ({
spotlightDisabled={!isVisible}
>
{/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */}
<div className={css.inputWrapper} onClick={(e) => e.stopPropagation()}>
<div
className={css.inputWrapper}
onClick={(e) => e.stopPropagation()}
>
<div className={css.searchInputWrapper}>
<TInput
className={css.inputBox}
@@ -260,14 +301,18 @@ const VoiceInputOverlay = ({
)}
onClick={handleMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
handleMicClick(e);
}
}}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} />
<img
src={micIcon}
alt="Microphone"
className={css.microphoneIcon}
/>
</div>
{currentMode === VOICE_MODES.LISTENING && (
<svg className={css.rippleSvg} width="100" height="100">
@@ -307,7 +352,7 @@ VoiceInputOverlay.propTypes = {
VoiceInputOverlay.defaultProps = {
mode: VOICE_MODES.PROMPT,
suggestions: [],
searchQuery: '',
searchQuery: "",
onSearchChange: null,
onSearchSubmit: null,
};

View File

@@ -10,6 +10,7 @@
align-items: center;
pointer-events: none; // 포커스 받지 않음
position: relative;
margin-top: -210px;
}
.listeningText {
@@ -71,7 +72,7 @@
.bar2 {
width: 510px;
left: 0;
background: #FFB3B3;
background: #ffb3b3;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.4s; // 가장 큰 막대 - 마지막
opacity: 0; // 애니메이션으로 제어
@@ -80,7 +81,7 @@
.bar3 {
width: 480px;
left: 15px;
background: #FF8080;
background: #ff8080;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.2s;
opacity: 0;
@@ -89,16 +90,16 @@
.bar4 {
width: 390px;
left: 60px;
background: #FF6666;
background: #ff6666;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.0s;
animation-delay: 1s;
opacity: 0;
}
.bar5 {
width: 350px;
left: 80px;
background: #FF4D4D;
background: #ff4d4d;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.8s;
opacity: 0;
@@ -107,7 +108,7 @@
.bar6 {
width: 320px;
left: 95px;
background: #FF3333;
background: #ff3333;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.6s;
opacity: 0;
@@ -116,7 +117,7 @@
.bar7 {
width: 260px;
left: 125px;
background: #FF1A1A;
background: #ff1a1a;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.4s;
opacity: 0;
@@ -125,7 +126,7 @@
.bar8 {
width: 200px;
left: 155px;
background: #FF0000;
background: #ff0000;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.2s;
opacity: 0;
@@ -134,7 +135,7 @@
.bar9 {
width: 150px;
left: 180px;
background: #E00000;
background: #e00000;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.1s;
opacity: 0;
@@ -143,7 +144,7 @@
.bar10 {
width: 100px;
left: 205px;
background: #CC0000;
background: #cc0000;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0s; // 가장 작은 막대 - 처음 시작
opacity: 0;
@@ -174,7 +175,8 @@
0% {
opacity: 1;
}
33%, 100% {
33%,
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import defaultMicImg
from '../../../../../assets/images/icons/ico_microphone.png';
import CustomImage from '../../../../components/CustomImage/CustomImage';
import css from './VoiceNotRecognized.module.less';
const VoiceNotRecognized = () => {
return (
<div className={css.container}>
<div className={css.micBox}>
<CustomImage src={defaultMicImg} className={css.microPhone} />
<span className={css.infoText}>
Voice is not recognized. Try again .
</span>
</div>
</div>
);
};
export default VoiceNotRecognized;

View File

@@ -0,0 +1,31 @@
@import "../../../../style/CommonStyle.module.less";
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
pointer-events: none; // 포커스 받지 않음
position: relative;
margin-top: -210px;
.micBox {
width: 634px;
height: 276px;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
text-align: center;
.microPhone {
height: 200px;
}
.infoText {
font-size: 42px;
font-weight: 700;
letter-spacing: -1px;
color: #fff;
}
}
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import css from './VoiceNotRecognizedCircle.module.less';
const VoiceNotRecognizedCircle = () => {
return (
<div className={css.container}>
<div className={css.micBox}>
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircleBackground}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#C70850"
strokeWidth="6"
/>
{/* 애니메이션 원 - 진행 상황 표시 */}
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#C70850"
strokeWidth="6"
/>
</svg>
<span className={css.infoText}>
Voice is not recognized. Try again .
</span>
</div>
</div>
);
};
export default VoiceNotRecognizedCircle;

View File

@@ -0,0 +1,53 @@
@import "../../../../style/CommonStyle.module.less";
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
pointer-events: none; // 포커스 받지 않음
position: relative;
margin-top: -210px;
padding-right: 120xp;
.micBox {
width: 634px;
height: 276px;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
text-align: center;
.infoText {
font-size: 42px;
font-weight: 700;
letter-spacing: -1px;
color: #fff;
}
}
}
.rippleCircleBackground {
stroke: rgba(199, 8, 80, 0.2);
stroke-width: 6;
opacity: 1;
}
.rippleCircle {
stroke-dasharray: 295.3; // 2 * PI * 47 (원의 둘레)
stroke-dashoffset: 295.3; // 초기값: 완전히 숨김
transform-origin: center;
transform: rotate(-90deg); // 12시 방향에서 시작
animation: drawCircleReco 2s ease-in-out infinite;
background-color: rgba(199, 8, 80, 0.2);
}
@keyframes drawCircleReco {
0% {
stroke-dashoffset: 295.3; // 점에서 시작
}
100% {
stroke-dashoffset: 0; // 원 완성 (계속 시계방향으로)
}
}