[251017] fix: VoiceInputOverlay expose condition

🕐 커밋 시간: 2025. 10. 17. 10:10:09

📊 변경 통계:
  • 총 파일: 4개
  • 추가: +170줄
  • 삭제: -124줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/hooks/useWebSpeech.js
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognized.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript):
     Added: Spottable()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceNotRecognized.jsx (javascript):
     Deleted: VoiceNotRecognized()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
This commit is contained in:
2025-10-17 10:10:11 +09:00
parent 473f58c68c
commit b025780107
4 changed files with 261 additions and 315 deletions

View File

@@ -63,11 +63,17 @@ export const useWebSpeech = (isActive, onSTTText, config = {}) => {
dispatch(stopWebSpeech());
}, [dispatch]);
// WebSpeech API 지원 여부 체크
const isSupported =
!webSpeech.error ||
(typeof webSpeech.error === 'string' && !webSpeech.error.includes('not supported'));
return {
isInitialized: webSpeech.isInitialized,
isListening: webSpeech.isListening,
interimText: webSpeech.interimText,
error: webSpeech.error,
isSupported,
startListening,
stopListening,
};

View File

@@ -1,43 +1,22 @@
// src/views/SearchPanel/SearchPanel.new.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { Job } from '@enact/core/util';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import { setContainerLastFocusedElement } from '@enact/spotlight/src/container';
import micIcon from '../../../assets/images/searchpanel/image-mic.png';
import hotPicksImage from '../../../assets/images/searchpanel/img-hotpicks.png';
import hotPicksBrandImage
from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import {
sendLogGNB,
sendLogTotalRecommend,
} from '../../actions/logActions';
import hotPicksBrandImage from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import { sendLogGNB, sendLogTotalRecommend } from '../../actions/logActions';
import { getMyRecommandedKeyword } from '../../actions/myPageActions';
import {
popPanel,
updatePanel,
} from '../../actions/panelActions';
import {
getSearch,
resetSearch,
searchMain,
} from '../../actions/searchActions';
import { popPanel, updatePanel } from '../../actions/panelActions';
import { getSearch, resetSearch, searchMain } from '../../actions/searchActions';
import {
showErrorToast,
showInfoToast,
@@ -47,77 +26,51 @@ import {
showWarningToast,
} from '../../actions/toastActions';
import TBody from '../../components/TBody/TBody';
import TInput, {
ICONS,
KINDS,
} from '../../components/TInput/TInput';
import TInput, { ICONS, KINDS } from '../../components/TInput/TInput';
import TPanel from '../../components/TPanel/TPanel';
import TScroller from '../../components/TScroller/TScroller';
import TVerticalPagenator
from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList
from '../../components/TVirtualGridList/TVirtualGridList';
import TVerticalPagenator from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList from '../../components/TVirtualGridList/TVirtualGridList';
// import VirtualKeyboardContainer from "../../components/TToast/VirtualKeyboardContainer";
import usePrevious from '../../hooks/usePrevious';
import {
LOG_CONTEXT_NAME,
LOG_MENU,
LOG_MESSAGE_ID,
panel_names,
} from '../../utils/Config';
import { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from '../../utils/Config';
import { SpotlightIds } from '../../utils/SpotlightIds';
import NoSearchResults from './NoSearchResults/NoSearchResults';
import RecommendedKeywords from './RecommendedKeywords/RecommendedKeywords';
import SearchInputOverlay from './SearchInpuOverlay';
import css from './SearchPanel.new.module.less';
import SearchResultsNew from './SearchResults.new';
import VoiceInputOverlay, {
VOICE_MODES,
} from './VoiceInputOverlay/VoiceInputOverlay';
import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';
const ContainerBasic = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const ContainerBasic = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// 검색 입력 영역 컨테이너
const InputContainer = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const InputContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// 콘텐츠 섹션 컨테이너
const SectionContainer = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const SectionContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// Spottable 컴포넌트들
const SpottableMicButton = Spottable("div");
const SpottableKeyword = Spottable("div");
const SpottableProduct = Spottable("div");
const SpottableLi = Spottable("li");
const SpottableMicButton = Spottable('div');
const SpottableKeyword = Spottable('div');
const SpottableProduct = Spottable('div');
const SpottableLi = Spottable('li');
const ITEMS_PER_PAGE = 9;
// Spotlight ID 상수
const SPOTLIGHT_IDS = {
SEARCH_INPUT_LAYER: "search-input-layer",
SEARCH_INPUT_BOX: "search-input-box",
MICROPHONE_BUTTON: "microphone-button",
RECENT_SEARCHES_SECTION: "recent-searches-section",
TOP_SEARCHES_SECTION: "top-searches-section",
POPULAR_BRANDS_SECTION: "popular-brands-section",
HOT_PICKS_SECTION: "hot-picks-section",
SEARCH_VERTICAL_PAGENATOR: "search_verticalPagenator",
SEARCH_INPUT_LAYER: 'search-input-layer',
SEARCH_INPUT_BOX: 'search-input-box',
MICROPHONE_BUTTON: 'microphone-button',
RECENT_SEARCHES_SECTION: 'recent-searches-section',
TOP_SEARCHES_SECTION: 'top-searches-section',
POPULAR_BRANDS_SECTION: 'popular-brands-section',
HOT_PICKS_SECTION: 'hot-picks-section',
SEARCH_VERTICAL_PAGENATOR: 'search_verticalPagenator',
};
export default function SearchPanel({
panelInfo,
isOnTop,
spotlightId,
scrollOptions = [],
}) {
export default function SearchPanel({ panelInfo, isOnTop, spotlightId, scrollOptions = [] }) {
const dispatch = useDispatch();
const loadingComplete = useSelector((state) => state.common?.loadingComplete);
const recommandedKeywords = useSelector(
@@ -126,17 +79,13 @@ export default function SearchPanel({
const { searchDatas: searchDatas } = useSelector((state) => state.search);
const searchPerformed = useSelector((state) => state.search.searchPerformed);
const panels = useSelector((state) => state.panels.panels);
const shopperHouseData = useSelector(
(state) => state.search.shopperHouseData
);
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
const [firstSpot, setFirstSpot] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [paginatedKeywords, setPaginatedKeywords] = useState([]);
const [pageChanged, setPageChanged] = useState(false);
const [searchQuery, setSearchQuery] = useState(
panelInfo.searchVal ? panelInfo.searchVal : null
);
const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal ? panelInfo.searchVal : null);
const [position, setPosition] = useState(null);
const [showVirtualKeyboard, setShowVirtualKeyboard] = useState(false);
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
@@ -155,45 +104,39 @@ export default function SearchPanel({
let searchQueryRef = usePrevious(searchQuery);
let isOnTopRef = usePrevious(isOnTop);
const firstButtonSpotlightId = "first-keyword-button";
const firstButtonSpotlightId = 'first-keyword-button';
const cbChangePageRef = useRef(null);
const [focusedContainerId, setFocusedContainerId] = useState(
panelInfo?.focusedContainerId
);
const [focusedContainerId, setFocusedContainerId] = useState(panelInfo?.focusedContainerId);
const focusedContainerIdRef = usePrevious(focusedContainerId);
const onFocusMic = useCallback(() => {
console.log('[MicFocus]');
// 포커스 시 VoiceInputOverlay 표시
setIsVoiceOverlayVisible(true);
}, []);
// 가짜 데이터 - 실제로는 Redux store나 API에서 가져와야 함
const recentSearches = useMemo(
() => ["Puppy food", "Dog toy", "Fitness"],
[]
);
const recentSearches = useMemo(() => ['Puppy food', 'Dog toy', 'Fitness'], []);
const recentResultSearches = useMemo(
() => [
"Puppy food",
"Dog toy",
'Puppy food',
'Dog toy',
"Mather's Day",
"Gift",
"Easter Day",
"Royal Canin puppy food2",
"Shark",
'Gift',
'Easter Day',
'Royal Canin puppy food2',
'Shark',
],
[]
);
const topSearches = useMemo(
() => [
"Mather's Day",
"Gift",
"Easter Day",
"Royal Canin puppy food",
"Fitness",
"Parrot",
],
() => ["Mather's Day", 'Gift', 'Easter Day', 'Royal Canin puppy food', 'Fitness', 'Parrot'],
[]
);
const popularBrands = useMemo(
() => ["Shark", "Ninja", "Skechers", "LocknLock", "8Greens", "LGE"],
() => ['Shark', 'Ninja', 'Skechers', 'LocknLock', '8Greens', 'LGE'],
[]
);
const hotPicks = useMemo(
@@ -202,32 +145,32 @@ export default function SearchPanel({
id: 1,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
brandName: 'Product Name',
title: 'New Shark Vacuum! Your pet Hair Solution!',
isForYou: false,
},
{
id: 2,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
brandName: 'Product Name',
title: 'New Shark Vacuum! Your pet Hair Solution!',
isForYou: false,
},
{
id: 3,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
brandName: 'Product Name',
title: 'New Shark Vacuum! Your pet Hair Solution!',
isForYou: false,
},
{
id: 4,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
brandName: 'Product Name',
title: 'New Shark Vacuum! Your pet Hair Solution!',
isForYou: true,
},
],
@@ -307,7 +250,7 @@ export default function SearchPanel({
dispatch(
sendLogTotalRecommend({
query: searchQuery,
searchType: searchPerformed ? "query" : "keyword",
searchType: searchPerformed ? 'query' : 'keyword',
result: result,
contextName: LOG_CONTEXT_NAME.SEARCH,
messageId: LOG_MESSAGE_ID.SEARCH_ITEM,
@@ -331,9 +274,9 @@ export default function SearchPanel({
if (query.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
service: 'com.lgshop.app',
query: query,
domain: "theme,show,item",
domain: 'theme,show,item',
})
);
@@ -366,8 +309,7 @@ export default function SearchPanel({
}, [currentPage]);
const hasPrevPage = currentPage > 1;
const hasNextPage =
currentPage * ITEMS_PER_PAGE < recommandedKeywords?.length;
const hasNextPage = currentPage * ITEMS_PER_PAGE < recommandedKeywords?.length;
useEffect(() => {
if (panelInfo && isOnTop) {
@@ -398,7 +340,7 @@ export default function SearchPanel({
}
// Enter 키 처리
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
if (showVirtualKeyboard) {
// 가상 키보드가 열려있으면 검색 실행하고 키보드 닫기
@@ -412,32 +354,26 @@ export default function SearchPanel({
// 방향키 처리 - Spotlight 네비게이션 허용
const arrowKeys = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Left",
"Right",
"Up",
"Down",
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Left',
'Right',
'Up',
'Down',
];
if (arrowKeys.includes(e.key)) {
// 입력 필드가 비어있고 왼쪽 화살표인 경우에만 방지
if (
position === 0 &&
(e.key === "Left" || e.key === "ArrowLeft") &&
!searchQuery
) {
if (position === 0 && (e.key === 'Left' || e.key === 'ArrowLeft') && !searchQuery) {
e.preventDefault();
return;
}
// 오른쪽 화살표 키 처리 - 포커스 이동 허용
if (e.key === "ArrowRight" || e.key === "Right") {
if (e.key === 'ArrowRight' || e.key === 'Right') {
// 커서가 텍스트 끝에 있을 때만 포커스 이동 허용
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input && position === input.value.length) {
// 커서가 텍스트 끝에 있으면 포커스 이동 허용
return;
@@ -452,9 +388,7 @@ export default function SearchPanel({
);
const cursorPosition = useCallback(() => {
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input) {
setPosition(input.selectionStart);
}
@@ -477,10 +411,10 @@ export default function SearchPanel({
setIsVoiceOverlayVisible(false);
return;
}
if (searchQuery === null || searchQuery === "") {
if (searchQuery === null || searchQuery === '') {
dispatch(popPanel(panel_names.SEARCH_PANEL));
} else {
setSearchQuery("");
setSearchQuery('');
setCurrentPage(1);
dispatch(resetSearch());
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
@@ -495,7 +429,7 @@ export default function SearchPanel({
Spotlight.resume();
setFirstSpot(true);
if (panelInfo.currentSpot) {
if (panels[panels.length - 1]?.name === "searchpanel") {
if (panels[panels.length - 1]?.name === 'searchpanel') {
Spotlight.focus(panelInfo.currentSpot);
}
}
@@ -535,13 +469,13 @@ export default function SearchPanel({
// 상품 클릭 핸들러
const handleProductClick = useCallback((product) => {
// 상품 상세 페이지로 이동하는 로직 구현
console.log("Product clicked:", product);
console.log('Product clicked:', product);
}, []);
// 테스트용 Toast 핸들러들
const handleTestToasts = useCallback(() => {
// 간단한 Toast 테스트
dispatch(showSuccessToast("테스트 메시지입니다", { duration: 3000 }));
dispatch(showSuccessToast('테스트 메시지입니다', { duration: 3000 }));
}, [dispatch]);
// ProductCard 컴포넌트
@@ -580,20 +514,12 @@ export default function SearchPanel({
<div className={css.productInfo}>
{showBrandLogo && (
<div className={css.productBrandWrapper}>
<img
src={brandLogo}
alt={brandName}
className={css.brandLogo}
/>
<img src={brandLogo} alt={brandName} className={css.brandLogo} />
</div>
)}
<div className={css.productDetails}>
{showBrandName && (
<div className={css.brandName}>{brandName}</div>
)}
{showProductTitle && (
<div className={css.productTitle}>{title}</div>
)}
{showBrandName && <div className={css.brandName}>{brandName}</div>}
{showProductTitle && <div className={css.productTitle}>{title}</div>}
</div>
</div>
</SpottableProduct>
@@ -612,16 +538,8 @@ export default function SearchPanel({
}, [searchPerformed, searchQuery, inputFocus]);
return (
<TPanel
className={css.container}
handleCancel={onCancel}
spotlightId={spotlightId}
>
<TBody
className={css.tBody}
scrollable={true}
spotlightDisabled={!isOnTop}
>
<TPanel className={css.container} handleCancel={onCancel} spotlightId={spotlightId}>
<TBody className={css.tBody} scrollable={true} spotlightDisabled={!isOnTop}>
<ContainerBasic>
{isOnTop && (
<TVerticalPagenator
@@ -640,10 +558,8 @@ export default function SearchPanel({
className={classNames(
css.inputContainer,
inputFocus === true && css.inputFocus,
searchDatas &&
css.searchValue /* 이건 결과값 있을때만. 조건 추가필요 */,
(isVoiceOverlayVisible || isSearchOverlayVisible) &&
css.hidden
searchDatas && css.searchValue /* 이건 결과값 있을때만. 조건 추가필요 */,
(isVoiceOverlayVisible || isSearchOverlayVisible) && css.hidden
)}
data-wheel-point={true}
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_LAYER}
@@ -674,19 +590,16 @@ export default function SearchPanel({
<SpottableMicButton
className={css.microphoneButton}
onClick={onClickMic}
onFocus={onFocusMic}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
onClickMic();
}
}}
spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON}
>
<div className={css.microphoneCircle}>
<img
src={micIcon}
alt="Microphone"
className={css.microphoneIcon}
/>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} />
</div>
</SpottableMicButton>
@@ -750,9 +663,7 @@ export default function SearchPanel({
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>
Your Recent Searches
</div>
<div className={css.sectionTitle}>Your Recent Searches</div>
</div>
<div className={css.keywordList}>
{recentSearches.map((keyword, index) => (
@@ -824,9 +735,7 @@ export default function SearchPanel({
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>
Hot Picks for You
</div>
<div className={css.sectionTitle}>Hot Picks for You</div>
</div>
<div className={css.productList}>
{hotPicks && hotPicks.length > 0 && (

View File

@@ -36,16 +36,27 @@ export const VOICE_MODES = {
PROMPT: 'prompt', // Try saying 화면
LISTENING: 'listening', // 듣는 중 화면
RESPONSE: 'response', // STT 텍스트 표시 화면
NOINIT: 'noinit', // 음성 인식이 초기화되지 않았을 때 화면
NOTRECOGNIZED: 'notrecognized', // 음성 인식이 되지 않았을 때 화면
MODE_3: 'mode3', // 추후 추가
MODE_4: 'mode4', // 추후 추가
};
// NOINIT 모드 에러 메시지
const NOINIT_ERROR_MESSAGE = 'Voice recognition is not supported on this device.';
// 음성인식 입력 모드 (VUI vs WebSpeech)
export const VOICE_INPUT_MODE = {
VUI: 'vui', // VUI (Voice UI Framework)
WEBSPEECH: 'webspeech', // Web Speech API
};
// Voice Version 상수 (어떤 음성 시스템을 사용할지 결정)
export const VOICE_VERSION = {
WEB_SPEECH: 'webspeech', // 1번: Web Speech API (기본값)
VUI: 'vui', // 2번: VUI Framework
};
const OVERLAY_SPOTLIGHT_ID = 'voice-input-overlay-container';
const INPUT_SPOTLIGHT_ID = 'voice-overlay-input-box';
const MIC_SPOTLIGHT_ID = 'voice-overlay-mic-button';
@@ -81,6 +92,8 @@ const VoiceInputOverlay = ({
const [voiceInputMode, setVoiceInputMode] = useState(null);
// STT 응답 텍스트 저장
const [sttResponseText, setSttResponseText] = useState('');
// Voice Version (어떤 음성 시스템을 사용할지 결정)
const [voiceVersion, setVoiceVersion] = useState(VOICE_VERSION.WEB_SPEECH);
// 🔊 Beep 소리 재생 함수
const playBeep = useCallback(() => {
@@ -163,15 +176,16 @@ const VoiceInputOverlay = ({
console.log('📺 [VoiceInputOverlay] Switching to RESPONSE mode with text:', sttText);
}, []);
const { isListening, interimText, startListening, stopListening, error } = useWebSpeech(
isVisible, // Overlay가 열려있을 때만 활성화 (voiceInputMode와 무관하게 초기화)
handleWebSpeechSTT,
{
lang: 'en-US',
continuous: false, // 침묵 감지 후 자동 종료
interimResults: true,
}
);
const { isListening, interimText, startListening, stopListening, error, isSupported } =
useWebSpeech(
isVisible, // Overlay가 열려있을 때만 활성화 (voiceInputMode와 무관하게 초기화)
handleWebSpeechSTT,
{
lang: 'en-US',
continuous: false, // 침묵 감지 후 자동 종료
interimResults: true,
}
);
// ⛔ VUI 테스트 비활성화: VoicePanel 독립 테스트 시 충돌 방지
// Redux에서 voice 상태 가져오기
@@ -248,6 +262,14 @@ const VoiceInputOverlay = ({
}
}, [error, voiceInputMode]);
// WebSpeech가 지원되지 않을 때 NOINIT 모드로 전환
useEffect(() => {
if (isVisible && voiceVersion === VOICE_VERSION.WEB_SPEECH && isSupported === false) {
console.log('⚠️ [VoiceInputOverlay] WebSpeech not supported, switching to NOINIT mode');
setCurrentMode(VOICE_MODES.NOINIT);
}
}, [isVisible, voiceVersion, isSupported]);
// WebSpeech listening 상태가 종료되어도 15초 타이머는 그대로 유지
// (음성 입력이 끝나도 listening 모드는 15초간 유지)
useEffect(() => {
@@ -478,6 +500,12 @@ const VoiceInputOverlay = ({
case VOICE_MODES.RESPONSE:
console.log('📺 Rendering: VoiceResponse with text:', sttResponseText);
return <VoiceResponse responseText={sttResponseText} onTalkAgain={handleTalkAgain} />;
case VOICE_MODES.NOINIT:
console.log('📺 Rendering: VoiceNotRecognized (NOINIT mode)');
return <VoiceNotRecognized prompt={NOINIT_ERROR_MESSAGE} />;
case VOICE_MODES.NOTRECOGNIZED:
console.log('📺 Rendering: VoiceNotRecognized (NOTRECOGNIZED mode)');
return <VoiceNotRecognized />;
case VOICE_MODES.MODE_3:
// 추후 MODE_3 컴포넌트 추가
return <VoiceNotRecognized />;
@@ -519,41 +547,43 @@ const VoiceInputOverlay = ({
setMicWebSpeechFocused(false);
}, []);
// ⛔ VUI 테스트 비활성화: VUI 마이크 버튼 클릭 핸들러
// VUI 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
// const handleVUIMicClick = useCallback(
// (e) => {
// console.log('[VoiceInputOverlay] handleVUIMicClick called, currentMode:', currentMode);
// VUI 마이크 버튼 클릭 핸들러 (voiceVersion이 VUI일 때만 작동)
const handleVUIMicClick = useCallback(
(e) => {
// voiceVersion이 VUI가 아니면 차단
if (voiceVersion !== VOICE_VERSION.VUI) return;
// // 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
// if (e && e.stopPropagation) {
// e.stopPropagation();
// }
// if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
// e.nativeEvent.stopImmediatePropagation();
// }
console.log('[VoiceInputOverlay] handleVUIMicClick called, currentMode:', currentMode);
// if (currentMode === VOICE_MODES.PROMPT) {
// // prompt 모드에서 클릭 시 -> VUI listening 모드로 전환
// console.log('[VoiceInputOverlay] Switching to VUI LISTENING mode');
// setVoiceInputMode(VOICE_INPUT_MODE.VUI);
// setCurrentMode(VOICE_MODES.LISTENING);
// // 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작
// // (이미 registerVoiceFramework()로 등록되어 있으므로)
// } else if (currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.VUI) {
// // VUI listening 모드에서 클릭 시 -> 종료
// console.log('[VoiceInputOverlay] Closing from VUI LISTENING mode');
// setVoiceInputMode(null);
// onClose();
// } else {
// // 기타 모드에서는 바로 종료
// console.log('[VoiceInputOverlay] Closing from other mode');
// setVoiceInputMode(null);
// onClose();
// }
// },
// [currentMode, voiceInputMode, onClose]
// );
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
e.stopPropagation();
}
if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
e.nativeEvent.stopImmediatePropagation();
}
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> VUI listening 모드로 전환
console.log('[VoiceInputOverlay] Switching to VUI LISTENING mode');
setVoiceInputMode(VOICE_INPUT_MODE.VUI);
setCurrentMode(VOICE_MODES.LISTENING);
// 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작
// (이미 registerVoiceFramework()로 등록되어 있으므로)
} else if (currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.VUI) {
// VUI listening 모드에서 클릭 시 -> 종료
console.log('[VoiceInputOverlay] Closing from VUI LISTENING mode');
setVoiceInputMode(null);
onClose();
} else {
// 기타 모드에서는 바로 종료
console.log('[VoiceInputOverlay] Closing from other mode');
setVoiceInputMode(null);
onClose();
}
},
[currentMode, voiceInputMode, voiceVersion, onClose]
);
// Overlay 닫기 핸들러 (모든 닫기 동작을 통합)
const handleClose = useCallback(() => {
@@ -643,7 +673,7 @@ const VoiceInputOverlay = ({
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
<div className={css.dimBackground} onClick={handleClose} />
{/* 디버깅용: WebSpeech 상태 표시 */}
{/* 디버깅용: Voice 상태 표시 */}
<div
style={{
position: 'absolute',
@@ -657,10 +687,15 @@ const VoiceInputOverlay = ({
fontSize: '14px',
}}
>
<div>Voice Version: {voiceVersion}</div>
<div>Input Mode: {voiceInputMode || 'None'}</div>
<div>Current Mode: {currentMode}</div>
<div>isListening: {isListening ? '🎤 YES' : '❌ NO'}</div>
<div>Interim: {interimText || 'N/A'}</div>
{voiceVersion === VOICE_VERSION.WEB_SPEECH && (
<>
<div>isListening: {isListening ? '🎤 YES' : '❌ NO'}</div>
<div>Interim: {interimText || 'N/A'}</div>
</>
)}
</div>
{/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */}
@@ -684,92 +719,91 @@ const VoiceInputOverlay = ({
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
{/* VUI 마이크 버튼 (⛔ 기능 비활성화: 클릭 핸들러만 무효화) */}
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.VUI &&
css.listening,
micFocused && css.focused
)}
onClick={(e) => {
// ⛔ VUI 테스트 비활성화: handleVUIMicClick 호출 안 함
e.stopPropagation();
console.log('[VoiceInputOverlay] VUI mic clicked (disabled for testing)');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
// ⛔ VUI 테스트 비활성화: handleVUIMicClick 호출 안 함
console.log('[VoiceInputOverlay] VUI mic Enter key (disabled for testing)');
}
}}
onFocus={handleMicFocus}
onBlur={handleMicBlur}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice AI" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.VUI && (
<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>
{/* WebSpeech 마이크 버튼 */}
<SpottableMicButton
className={classNames(
css.microphoneButtonWebSpeech,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH &&
css.listening,
micWebSpeechFocused && css.focused
)}
onClick={handleWebSpeechMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleWebSpeechMicClick(e);
}
}}
onFocus={handleMicWebSpeechFocus}
onBlur={handleMicWebSpeechBlur}
spotlightId={MIC_WEBSPEECH_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice Input" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#4A90E2"
strokeWidth="6"
/>
</svg>
{/* voiceVersion에 따라 하나의 마이크만 표시 */}
{voiceVersion === VOICE_VERSION.WEB_SPEECH && (
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH &&
css.listening,
micFocused && css.focused
)}
</SpottableMicButton>
onClick={handleWebSpeechMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleWebSpeechMicClick(e);
}
}}
onFocus={handleMicFocus}
onBlur={handleMicBlur}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice Input" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH && (
<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>
)}
{voiceVersion === VOICE_VERSION.VUI && (
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.VUI &&
css.listening,
micFocused && css.focused
)}
onClick={handleVUIMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleVUIMicClick(e);
}
}}
onFocus={handleMicFocus}
onBlur={handleMicBlur}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice AI" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.VUI && (
<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>

View File

@@ -1,18 +1,15 @@
import React from 'react';
import defaultMicImg
from '../../../../../assets/images/icons/ico_microphone.png';
import defaultMicImg from '../../../../../assets/images/icons/ico_microphone.png';
import CustomImage from '../../../../components/CustomImage/CustomImage';
import css from './VoiceNotRecognized.module.less';
const VoiceNotRecognized = () => {
const VoiceNotRecognized = ({ prompt = 'Voice is not recognized. Try again .' }) => {
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>
<span className={css.infoText}>{prompt}</span>
</div>
</div>
);