[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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user