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

View File

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