[251016] fix: VoiceInputOverlay ShopperHouse connect
🕐 커밋 시간: 2025. 10. 16. 10:26:16 📊 변경 통계: • 총 파일: 7개 • 추가: +65줄 • 삭제: -36줄 📁 추가된 파일: + com.twin.app.shoptime/vui-implement.md + com.twin.app.shoptime/vui-react.md 📝 수정된 파일: ~ com.twin.app.shoptime/src/actions/actionTypes.js ~ com.twin.app.shoptime/src/actions/searchActions.js ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/actions/searchActions.js (javascript): 🔄 Modified: updateSearchTimestamp() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less (unknown): ✅ Added: scaleY() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx (javascript): ✅ Added: VoicePromptScreen() ❌ Deleted: SpotlightContainerDecorator() 📄 com.twin.app.shoptime/vui-implement.md (md파일): ✅ Added: dispatch(), Date(), useDispatch(), useSelector(), useEffect(), onSTTText(), SearchPanel(), useState(), useCallback(), setSearchQuery(), getSearch(), setIsVoiceOverlayVisible(), useSearchVoice(), setVoiceMode(), handleSearchSubmit(), onSearchChange(), setCurrentMode(), setTimeout(), onClose(), stopPropagation(), Search(), App(), getLaunchParams(), clearLaunchParams(), pushPanel(), Input(), registerVoiceFramework(), performAction(), handleSTTText() 📄 com.twin.app.shoptime/vui-react.md (md파일): ✅ Added: Interface(), Search(), Input(), function(), register(), App(), useDispatch(), useCallback(), getLaunchParams(), clearLaunchParams(), dispatch(), pushPanel(), useEffect(), SearchPanel(), getSearch(), onSuccess(), cancel(), LS2Request(), onCommand(), onFailure(), instance(), deleteInstance(), useRef(), onVoiceInput(), reportActionResult(), registerVoiceConductor(), setVoiceContext(), unregisterVoiceConductor(), setSearchQuery(), useVoiceConductor(), handleSearchSubmit(), setContext() 🔧 주요 변경 내용: • 타입 시스템 안정성 강화 • 핵심 비즈니스 로직 개선 • 개발 문서 및 가이드 개선
This commit is contained in:
@@ -293,4 +293,5 @@ export const types = {
|
|||||||
VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE',
|
VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE',
|
||||||
VOICE_ADD_LOG: 'VOICE_ADD_LOG',
|
VOICE_ADD_LOG: 'VOICE_ADD_LOG',
|
||||||
VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS',
|
VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS',
|
||||||
|
VOICE_STT_TEXT_RECEIVED: 'VOICE_STT_TEXT_RECEIVED', // STT 텍스트 수신
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export const getShopperHouseSearch =
|
|||||||
if (searchId) {
|
if (searchId) {
|
||||||
params.searchid = searchId;
|
params.searchid = searchId;
|
||||||
}
|
}
|
||||||
|
console.log('[ShopperHouse] getShopperHouseSearch params: ', JSON.stringify(params));
|
||||||
|
|
||||||
TAxios(dispatch, getState, 'post', URLS.GET_SHOPPERHOUSE_SEARCH, {}, params, onSuccess, onFail);
|
TAxios(dispatch, getState, 'post', URLS.GET_SHOPPERHOUSE_SEARCH, {}, params, onSuccess, onFail);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ 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 } from 'react-redux';
|
||||||
import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput';
|
import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput';
|
||||||
import TFullPopup from '../../../components/TFullPopup/TFullPopup';
|
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 css from './VoiceInputOverlay.module.less';
|
import css from './VoiceInputOverlay.module.less';
|
||||||
import VoicePromptScreen from './modes/VoicePromptScreen';
|
import VoicePromptScreen from './modes/VoicePromptScreen';
|
||||||
import VoiceListening from './modes/VoiceListening';
|
import VoiceListening from './modes/VoiceListening';
|
||||||
@@ -44,6 +46,7 @@ const VoiceInputOverlay = ({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSearchSubmit,
|
onSearchSubmit,
|
||||||
}) => {
|
}) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
const lastFocusedElement = useRef(null);
|
const lastFocusedElement = useRef(null);
|
||||||
const [inputFocus, setInputFocus] = useState(false);
|
const [inputFocus, setInputFocus] = useState(false);
|
||||||
// 내부 모드 상태 관리 (prompt -> listening -> close)
|
// 내부 모드 상태 관리 (prompt -> listening -> close)
|
||||||
@@ -72,11 +75,52 @@ const VoiceInputOverlay = ({
|
|||||||
}
|
}
|
||||||
}, [isVisible, mode]);
|
}, [isVisible, mode]);
|
||||||
|
|
||||||
|
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트만 설정
|
||||||
|
const handleSuggestionClick = useCallback(
|
||||||
|
(suggestion) => {
|
||||||
|
console.log('[VoiceInputOverlay] Suggestion clicked:', suggestion);
|
||||||
|
// 따옴표 제거
|
||||||
|
const query = suggestion.replace(/^["']|["']$/g, '').trim();
|
||||||
|
// Input 창에 텍스트 설정
|
||||||
|
if (onSearchChange) {
|
||||||
|
onSearchChange({ value: query });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSearchChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Input 창에서 API 호출 핸들러 (엔터키 또는 돋보기 아이콘 클릭)
|
||||||
|
const handleSearchSubmit = useCallback(() => {
|
||||||
|
console.log('[VoiceInputOverlay] Search submit:', searchQuery);
|
||||||
|
if (searchQuery && searchQuery.trim()) {
|
||||||
|
// ShopperHouse API 호출
|
||||||
|
dispatch(getShopperHouseSearch(searchQuery.trim()));
|
||||||
|
|
||||||
|
// VoiceInputOverlay는 SearchPanel과 다른 API를 사용하므로 onSearchSubmit 호출 안 함
|
||||||
|
// if (onSearchSubmit) {
|
||||||
|
// onSearchSubmit(searchQuery);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}, [dispatch, searchQuery]);
|
||||||
|
|
||||||
|
// Input 창에서 엔터키 핸들러
|
||||||
|
const handleInputKeyDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearchSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearchSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
// 모드에 따른 컨텐츠 렌더링
|
// 모드에 따른 컨텐츠 렌더링
|
||||||
const renderModeContent = () => {
|
const renderModeContent = () => {
|
||||||
switch (currentMode) {
|
switch (currentMode) {
|
||||||
case VOICE_MODES.PROMPT:
|
case VOICE_MODES.PROMPT:
|
||||||
return <VoicePromptScreen suggestions={suggestions} />;
|
return (
|
||||||
|
<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:
|
||||||
@@ -86,7 +130,9 @@ const VoiceInputOverlay = ({
|
|||||||
// 추후 MODE_4 컴포넌트 추가
|
// 추후 MODE_4 컴포넌트 추가
|
||||||
return <div>Mode 4 (Coming soon)</div>;
|
return <div>Mode 4 (Coming soon)</div>;
|
||||||
default:
|
default:
|
||||||
return <VoicePromptScreen suggestions={suggestions} />;
|
return (
|
||||||
|
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,37 +146,43 @@ const VoiceInputOverlay = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
|
// 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
|
||||||
const handleMicClick = useCallback((e) => {
|
const handleMicClick = useCallback(
|
||||||
console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode);
|
(e) => {
|
||||||
|
console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode);
|
||||||
|
|
||||||
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
|
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
|
||||||
if (e && e.stopPropagation) {
|
if (e && e.stopPropagation) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
|
if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
|
||||||
e.nativeEvent.stopImmediatePropagation();
|
e.nativeEvent.stopImmediatePropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
} 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();
|
||||||
}
|
}
|
||||||
}, [currentMode, onClose]);
|
},
|
||||||
|
[currentMode, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
// dim 레이어 클릭 핸들러 (마이크 버튼과 분리)
|
// dim 레이어 클릭 핸들러 (마이크 버튼과 분리)
|
||||||
const handleDimClick = useCallback((e) => {
|
const handleDimClick = useCallback(
|
||||||
console.log('[VoiceInputOverlay] dimBackground clicked');
|
(e) => {
|
||||||
onClose();
|
console.log('[VoiceInputOverlay] dimBackground clicked');
|
||||||
}, [onClose]);
|
onClose();
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TFullPopup
|
<TFullPopup
|
||||||
@@ -140,7 +192,7 @@ const VoiceInputOverlay = ({
|
|||||||
spotlightRestrict="self-only"
|
spotlightRestrict="self-only"
|
||||||
spotlightId={OVERLAY_SPOTLIGHT_ID}
|
spotlightId={OVERLAY_SPOTLIGHT_ID}
|
||||||
noAnimation={false}
|
noAnimation={false}
|
||||||
scrimType="none"
|
scrimType="transparent"
|
||||||
className={css.tFullPopupWrapper}
|
className={css.tFullPopupWrapper}
|
||||||
>
|
>
|
||||||
<div className={css.voiceOverlayContainer}>
|
<div className={css.voiceOverlayContainer}>
|
||||||
@@ -162,7 +214,8 @@ const VoiceInputOverlay = ({
|
|||||||
icon={ICONS.search}
|
icon={ICONS.search}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={onSearchChange}
|
onChange={onSearchChange}
|
||||||
onIconClick={() => onSearchSubmit && onSearchSubmit(searchQuery)}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
onIconClick={handleSearchSubmit}
|
||||||
spotlightId={INPUT_SPOTLIGHT_ID}
|
spotlightId={INPUT_SPOTLIGHT_ID}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
.bar2 {
|
.bar2 {
|
||||||
width: 510px;
|
width: 510px;
|
||||||
left: 0;
|
left: 0;
|
||||||
background: @PRIMARY_COLOR_RED;
|
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 +80,7 @@
|
|||||||
.bar3 {
|
.bar3 {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
left: 15px;
|
left: 15px;
|
||||||
background: @PRIMARY_COLOR_RED;
|
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,7 +89,7 @@
|
|||||||
.bar4 {
|
.bar4 {
|
||||||
width: 390px;
|
width: 390px;
|
||||||
left: 60px;
|
left: 60px;
|
||||||
background: @PRIMARY_COLOR_RED;
|
background: #FF6666;
|
||||||
animation: waveAppear 1.6s ease-in-out infinite;
|
animation: waveAppear 1.6s ease-in-out infinite;
|
||||||
animation-delay: 1.0s;
|
animation-delay: 1.0s;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
.bar5 {
|
.bar5 {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
left: 80px;
|
left: 80px;
|
||||||
background: @PRIMARY_COLOR_RED;
|
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 +107,7 @@
|
|||||||
.bar6 {
|
.bar6 {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
left: 95px;
|
left: 95px;
|
||||||
background: @PRIMARY_COLOR_RED;
|
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 +116,7 @@
|
|||||||
.bar7 {
|
.bar7 {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
left: 125px;
|
left: 125px;
|
||||||
background: @PRIMARY_COLOR_RED;
|
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 +125,7 @@
|
|||||||
.bar8 {
|
.bar8 {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
left: 155px;
|
left: 155px;
|
||||||
background: @PRIMARY_COLOR_RED;
|
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 +134,7 @@
|
|||||||
.bar9 {
|
.bar9 {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
left: 180px;
|
left: 180px;
|
||||||
background: @PRIMARY_COLOR_RED;
|
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 +143,7 @@
|
|||||||
.bar10 {
|
.bar10 {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
left: 205px;
|
left: 205px;
|
||||||
background: @PRIMARY_COLOR_RED;
|
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;
|
||||||
@@ -155,7 +155,11 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scaleY(0);
|
transform: scaleY(0);
|
||||||
}
|
}
|
||||||
50% {
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scaleY(1);
|
transform: scaleY(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx
|
// src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import Spottable from '@enact/spotlight/Spottable';
|
import Spottable from '@enact/spotlight/Spottable';
|
||||||
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
|
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
|
||||||
import { getShopperHouseSearch } from '../../../../actions/searchActions';
|
|
||||||
import css from './VoicePromptScreen.module.less';
|
import css from './VoicePromptScreen.module.less';
|
||||||
|
|
||||||
const SpottableBubble = Spottable('div');
|
const SpottableBubble = Spottable('div');
|
||||||
@@ -17,27 +15,14 @@ const PromptContainer = SpotlightContainerDecorator(
|
|||||||
'div'
|
'div'
|
||||||
);
|
);
|
||||||
|
|
||||||
const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => {
|
const VoicePromptScreen = ({ title = 'Try saying', suggestions = [], onSuggestionClick }) => {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
|
|
||||||
const prevDataRef = useRef(null);
|
|
||||||
|
|
||||||
// ShopperHouse 데이터가 변경될 때 콘솔 출력
|
|
||||||
useEffect(() => {
|
|
||||||
if (shopperHouseData && shopperHouseData !== prevDataRef.current) {
|
|
||||||
console.log('[ShopperHouse]', JSON.stringify(shopperHouseData, null, 2));
|
|
||||||
prevDataRef.current = shopperHouseData;
|
|
||||||
}
|
|
||||||
}, [shopperHouseData]);
|
|
||||||
|
|
||||||
const handleBubbleClick = (suggestion, index) => {
|
const handleBubbleClick = (suggestion, index) => {
|
||||||
console.log(`Bubble clicked: ${suggestion}`, index);
|
console.log(`[VoicePromptScreen] Bubble clicked: ${suggestion}`, index);
|
||||||
|
|
||||||
// suggestion에서 따옴표 제거하여 검색어 추출
|
// 부모 컴포넌트로 suggestion 텍스트 전달 (API 호출은 부모에서 처리)
|
||||||
const query = suggestion.replace(/^["']|["']$/g, '').trim();
|
if (onSuggestionClick) {
|
||||||
|
onSuggestionClick(suggestion);
|
||||||
// ShopperHouse API 호출
|
}
|
||||||
dispatch(getShopperHouseSearch(query));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,6 +51,7 @@ const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => {
|
|||||||
VoicePromptScreen.propTypes = {
|
VoicePromptScreen.propTypes = {
|
||||||
title: PropTypes.string,
|
title: PropTypes.string,
|
||||||
suggestions: PropTypes.arrayOf(PropTypes.string),
|
suggestions: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
onSuggestionClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
VoicePromptScreen.defaultProps = {
|
VoicePromptScreen.defaultProps = {
|
||||||
|
|||||||
604
com.twin.app.shoptime/vui-implement.md
Normal file
604
com.twin.app.shoptime/vui-implement.md
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
📋 구현 계획
|
||||||
|
|
||||||
|
● 이제 각 단계별로 자세히 설명드리겠습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
🔧 구체적인 구현 방법
|
||||||
|
|
||||||
|
1️⃣ voiceActions.js에 UseIME Intent 추가
|
||||||
|
|
||||||
|
현재 문제: sendVoiceIntents 함수가 Select, Scroll만 등록하고 UseIME는 없음
|
||||||
|
|
||||||
|
해결 방법: UseIME Intent를 추가하고, STT 텍스트를 Redux로 dispatch
|
||||||
|
|
||||||
|
// src/actions/voiceActions.js - sendVoiceIntents 수정
|
||||||
|
|
||||||
|
export const sendVoiceIntents = (voiceTicket) => (dispatch, getState) => {
|
||||||
|
console.log('[Voice] Sending voice intents...');
|
||||||
|
|
||||||
|
const inAppIntents = [
|
||||||
|
// ⭐ UseIME Intent 추가 - 음성 입력 텍스트 받기
|
||||||
|
{
|
||||||
|
intent: 'UseIME',
|
||||||
|
supportAsrOnly: true, // STT만 사용 (NLP 불필요)
|
||||||
|
},
|
||||||
|
// 기존 intents...
|
||||||
|
{
|
||||||
|
intent: 'Select',
|
||||||
|
supportOrdinal: true,
|
||||||
|
items: [
|
||||||
|
// ... 기존 items
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
intent: 'Scroll',
|
||||||
|
supportOrdinal: false,
|
||||||
|
items: [
|
||||||
|
// ... 기존 items
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ... 나머지 코드 동일
|
||||||
|
};
|
||||||
|
|
||||||
|
handleVoiceAction 수정 - UseIME 처리 추가:
|
||||||
|
|
||||||
|
// src/actions/voiceActions.js
|
||||||
|
|
||||||
|
export const handleVoiceAction = (voiceTicket, action) => (dispatch, getState) => {
|
||||||
|
console.log('[Voice] Handling voice action:', action);
|
||||||
|
|
||||||
|
let result = false;
|
||||||
|
let feedback = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ⭐ UseIME Intent 처리 추가
|
||||||
|
if (action.intent === 'UseIME' && action.value) {
|
||||||
|
console.log('[Voice] STT Text received:', action.value);
|
||||||
|
|
||||||
|
// STT 텍스트를 Redux로 dispatch
|
||||||
|
dispatch({
|
||||||
|
type: types.VOICE_STT_TEXT_RECEIVED,
|
||||||
|
payload: action.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
result = true;
|
||||||
|
feedback = {
|
||||||
|
voiceUi: {
|
||||||
|
systemUtterance: `Searching for ${action.value}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (action.intent === 'Select' && action.itemId) {
|
||||||
|
result = dispatch(handleSelectIntent(action.itemId));
|
||||||
|
}
|
||||||
|
else if (action.intent === 'Scroll' && action.itemId) {
|
||||||
|
result = dispatch(handleScrollIntent(action.itemId));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.warn('[Voice] Unknown intent:', action);
|
||||||
|
result = false;
|
||||||
|
feedback = {
|
||||||
|
voiceUi: {
|
||||||
|
systemUtterance: 'This action is not supported',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Voice] Error processing action:', error);
|
||||||
|
result = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(reportActionResult(voiceTicket, result, feedback));
|
||||||
|
};
|
||||||
|
|
||||||
|
---
|
||||||
|
2️⃣ actionTypes.js에 새 타입 추가
|
||||||
|
|
||||||
|
// src/actions/actionTypes.js
|
||||||
|
|
||||||
|
export const types = {
|
||||||
|
// ... 기존 types
|
||||||
|
|
||||||
|
// Voice 관련
|
||||||
|
VOICE_REGISTER_SUCCESS: 'VOICE_REGISTER_SUCCESS',
|
||||||
|
VOICE_REGISTER_FAILURE: 'VOICE_REGISTER_FAILURE',
|
||||||
|
VOICE_SET_TICKET: 'VOICE_SET_TICKET',
|
||||||
|
VOICE_SET_CONTEXT_SUCCESS: 'VOICE_SET_CONTEXT_SUCCESS',
|
||||||
|
VOICE_SET_CONTEXT_FAILURE: 'VOICE_SET_CONTEXT_FAILURE',
|
||||||
|
VOICE_UPDATE_INTENTS: 'VOICE_UPDATE_INTENTS',
|
||||||
|
VOICE_PERFORM_ACTION: 'VOICE_PERFORM_ACTION',
|
||||||
|
VOICE_REPORT_RESULT_SUCCESS: 'VOICE_REPORT_RESULT_SUCCESS',
|
||||||
|
VOICE_REPORT_RESULT_FAILURE: 'VOICE_REPORT_RESULT_FAILURE',
|
||||||
|
VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE',
|
||||||
|
VOICE_ADD_LOG: 'VOICE_ADD_LOG',
|
||||||
|
VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS',
|
||||||
|
|
||||||
|
// ⭐ 새로 추가
|
||||||
|
VOICE_STT_TEXT_RECEIVED: 'VOICE_STT_TEXT_RECEIVED',
|
||||||
|
};
|
||||||
|
|
||||||
|
---
|
||||||
|
3️⃣ voiceReducer.js 수정
|
||||||
|
|
||||||
|
// src/reducers/voiceReducer.js
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
// ... 기존 state
|
||||||
|
|
||||||
|
// ⭐ STT 텍스트 state 추가
|
||||||
|
lastSTTText: null,
|
||||||
|
sttTimestamp: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const voiceReducer = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
// ... 기존 cases
|
||||||
|
|
||||||
|
// ⭐ STT 텍스트 수신 처리
|
||||||
|
case types.VOICE_STT_TEXT_RECEIVED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lastSTTText: action.payload,
|
||||||
|
sttTimestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
case types.VOICE_CLEAR_STATE:
|
||||||
|
return {
|
||||||
|
...initialState,
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
---
|
||||||
|
4️⃣ SearchPanel에서 VUI 통합
|
||||||
|
|
||||||
|
방법 1: Custom Hook 생성 (권장)
|
||||||
|
|
||||||
|
// src/hooks/useSearchVoice.js
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { registerVoiceFramework, unregisterVoiceFramework } from '../actions/voiceActions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SearchPanel용 음성 입력 Hook
|
||||||
|
* - SearchPanel이 foreground일 때 voice framework 등록
|
||||||
|
* - STT 텍스트를 자동으로 searchQuery로 설정
|
||||||
|
*/
|
||||||
|
export const useSearchVoice = (isOnTop, onSTTText) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
|
||||||
|
|
||||||
|
// SearchPanel이 foreground일 때만 voice 등록
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOnTop) {
|
||||||
|
console.log('[useSearchVoice] Registering voice framework');
|
||||||
|
dispatch(registerVoiceFramework());
|
||||||
|
} else {
|
||||||
|
console.log('[useSearchVoice] Unregistering voice framework');
|
||||||
|
dispatch(unregisterVoiceFramework());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
dispatch(unregisterVoiceFramework());
|
||||||
|
};
|
||||||
|
}, [isOnTop, dispatch]);
|
||||||
|
|
||||||
|
// STT 텍스트 수신 처리
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastSTTText && sttTimestamp) {
|
||||||
|
console.log('[useSearchVoice] STT text received:', lastSTTText);
|
||||||
|
onSTTText && onSTTText(lastSTTText);
|
||||||
|
}
|
||||||
|
}, [lastSTTText, sttTimestamp, onSTTText]);
|
||||||
|
};
|
||||||
|
|
||||||
|
SearchPanel.jsx 수정:
|
||||||
|
|
||||||
|
// src/views/SearchPanel/SearchPanel.jsx
|
||||||
|
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { useSearchVoice } from '../../hooks/useSearchVoice';
|
||||||
|
import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';
|
||||||
|
|
||||||
|
export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal || '');
|
||||||
|
|
||||||
|
// ⭐ Voice Overlay 상태 관리
|
||||||
|
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
|
||||||
|
const [voiceMode, setVoiceMode] = useState(VOICE_MODES.PROMPT);
|
||||||
|
|
||||||
|
// ⭐ STT 텍스트 수신 핸들러
|
||||||
|
const handleSTTText = useCallback((sttText) => {
|
||||||
|
console.log('[SearchPanel] STT text received:', sttText);
|
||||||
|
|
||||||
|
// 1. searchQuery 업데이트
|
||||||
|
setSearchQuery(sttText);
|
||||||
|
|
||||||
|
// 2. 자동 검색 실행
|
||||||
|
if (sttText && sttText.trim()) {
|
||||||
|
dispatch(
|
||||||
|
getSearch({
|
||||||
|
service: 'com.lgshop.app',
|
||||||
|
query: sttText.trim(),
|
||||||
|
domain: 'theme,show,item',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Voice Overlay 닫기 (선택사항)
|
||||||
|
setIsVoiceOverlayVisible(false);
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ⭐ Voice Hook 활성화
|
||||||
|
useSearchVoice(isOnTop, handleSTTText);
|
||||||
|
|
||||||
|
// 마이크 버튼 클릭 핸들러
|
||||||
|
const handleMicButtonClick = useCallback(() => {
|
||||||
|
console.log('[SearchPanel] Mic button clicked');
|
||||||
|
setVoiceMode(VOICE_MODES.PROMPT);
|
||||||
|
setIsVoiceOverlayVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVoiceOverlayClose = useCallback(() => {
|
||||||
|
console.log('[SearchPanel] Voice overlay closed');
|
||||||
|
setIsVoiceOverlayVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TPanel className={css.container} handleCancel={onCancel} spotlightId={spotlightId}>
|
||||||
|
<TBody className={css.tBody}>
|
||||||
|
{/* 기존 SearchPanel UI */}
|
||||||
|
<TInput
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onIconClick={() => handleSearchSubmit(searchQuery)}
|
||||||
|
// ... props
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ⭐ 마이크 버튼 추가 (선택사항) */}
|
||||||
|
<button onClick={handleMicButtonClick}>
|
||||||
|
🎤 Voice Search
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ⭐ Voice Overlay */}
|
||||||
|
<VoiceInputOverlay
|
||||||
|
isVisible={isVoiceOverlayVisible}
|
||||||
|
onClose={handleVoiceOverlayClose}
|
||||||
|
mode={voiceMode}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={(e) => setSearchQuery(e.value)}
|
||||||
|
onSearchSubmit={handleSearchSubmit}
|
||||||
|
suggestions={paginatedKeywords?.map(k => k.keyword) || []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 검색 결과 등 나머지 UI */}
|
||||||
|
</TBody>
|
||||||
|
</TPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
5️⃣ VoiceInputOverlay 개선
|
||||||
|
|
||||||
|
마이크 버튼 클릭 시 실제 음성 입력 시작:
|
||||||
|
|
||||||
|
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
|
||||||
|
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
const VoiceInputOverlay = ({
|
||||||
|
isVisible,
|
||||||
|
onClose,
|
||||||
|
mode,
|
||||||
|
suggestions,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
onSearchSubmit,
|
||||||
|
}) => {
|
||||||
|
const [currentMode, setCurrentMode] = useState(mode);
|
||||||
|
|
||||||
|
// ⭐ Redux에서 voice 상태 가져오기
|
||||||
|
const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
|
||||||
|
|
||||||
|
// ⭐ STT 텍스트 수신 시 listening 모드로 전환
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastSTTText && isVisible) {
|
||||||
|
console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);
|
||||||
|
|
||||||
|
// 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
|
||||||
|
if (onSearchChange) {
|
||||||
|
onSearchChange({ value: lastSTTText });
|
||||||
|
}
|
||||||
|
|
||||||
|
// listening 모드로 전환 (시각적 피드백)
|
||||||
|
setCurrentMode(VOICE_MODES.LISTENING);
|
||||||
|
|
||||||
|
// 1초 후 자동 닫기 (선택사항)
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);
|
||||||
|
|
||||||
|
// 마이크 버튼 클릭 핸들러
|
||||||
|
const handleMicClick = useCallback((e) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
|
||||||
|
if (currentMode === VOICE_MODES.PROMPT) {
|
||||||
|
// prompt 모드에서 클릭 시 -> listening 모드로 전환
|
||||||
|
console.log('[VoiceInputOverlay] Switching to LISTENING mode');
|
||||||
|
setCurrentMode(VOICE_MODES.LISTENING);
|
||||||
|
|
||||||
|
// 이 시점에서 webOS Voice Framework가 자동으로 음성 인식 시작
|
||||||
|
// (이미 registerVoiceFramework()로 등록되어 있으므로)
|
||||||
|
} else if (currentMode === VOICE_MODES.LISTENING) {
|
||||||
|
// listening 모드에서 클릭 시 -> 종료
|
||||||
|
console.log('[VoiceInputOverlay] Closing from LISTENING mode');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [currentMode, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TFullPopup open={isVisible} onClose={onClose}>
|
||||||
|
{/* 기존 UI */}
|
||||||
|
|
||||||
|
{/* Voice 등록 상태 표시 (디버깅용, 나중에 제거 가능) */}
|
||||||
|
{__DEV__ && (
|
||||||
|
<div style={{ position: 'absolute', top: 10, right: 10, color: '#fff' }}>
|
||||||
|
Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 나머지 UI */}
|
||||||
|
</TFullPopup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
---
|
||||||
|
6️⃣ Global Voice Search (handleRelaunch) 구현
|
||||||
|
|
||||||
|
App.js 수정:
|
||||||
|
|
||||||
|
// src/App/App.js
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { pushPanel } from '../actions/panelActions';
|
||||||
|
import { panel_names } from '../utils/Config';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// ⭐ webOSRelaunch 이벤트 핸들러
|
||||||
|
const handleRelaunchEvent = useCallback(() => {
|
||||||
|
console.log('[App] handleRelaunchEvent triggered');
|
||||||
|
|
||||||
|
const launchParams = getLaunchParams();
|
||||||
|
clearLaunchParams();
|
||||||
|
|
||||||
|
if (!launchParams) return;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ⭐ Voice Intent 처리 (최우선)
|
||||||
|
// ========================================
|
||||||
|
if (launchParams.intent) {
|
||||||
|
const { intent, intentParam, languageCode } = launchParams;
|
||||||
|
console.log('[App] Voice Intent received:', { intent, intentParam, languageCode });
|
||||||
|
|
||||||
|
// SearchContent 또는 PlayContent intent 처리
|
||||||
|
if (intent === 'SearchContent' || intent === 'PlayContent') {
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.SEARCH_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
voiceSearch: true, // 음성 검색 플래그
|
||||||
|
searchVal: intentParam, // 검색어
|
||||||
|
languageCode: languageCode,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[App] Opening SearchPanel with voice query: ${intentParam}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 기존 deeplink 처리
|
||||||
|
// ========================================
|
||||||
|
if (launchParams.contentTarget) {
|
||||||
|
console.log('[App] DeepLink:', launchParams.contentTarget);
|
||||||
|
// dispatch(handleDeepLink(launchParams.contentTarget));
|
||||||
|
}
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ⭐ webOSRelaunch 이벤트 리스너 등록
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('webOSRelaunch', handleRelaunchEvent);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('webOSRelaunch', handleRelaunchEvent);
|
||||||
|
};
|
||||||
|
}, [handleRelaunchEvent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{/* 앱 컴포넌트 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⭐ Launch 파라미터 헬퍼 함수
|
||||||
|
function getLaunchParams() {
|
||||||
|
if (window.PalmSystem) {
|
||||||
|
try {
|
||||||
|
const params = JSON.parse(window.PalmSystem.launchParams || '{}');
|
||||||
|
console.log('[App] Launch params:', params);
|
||||||
|
return params;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[App] Failed to parse launch params:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLaunchParams() {
|
||||||
|
if (window.PalmSystem) {
|
||||||
|
window.PalmSystem.launchParams = '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
|
---
|
||||||
|
🎯 전체 흐름 요약
|
||||||
|
|
||||||
|
Foreground Voice Input (VoiceInputOverlay)
|
||||||
|
|
||||||
|
1. SearchPanel이 foreground가 됨 (isOnTop = true)
|
||||||
|
↓
|
||||||
|
2. useSearchVoice Hook이 registerVoiceFramework() dispatch
|
||||||
|
↓
|
||||||
|
3. voiceActions.js에서 luna://com.webos.service.voiceconductor/interactor/register 호출
|
||||||
|
↓
|
||||||
|
4. voiceTicket 수신
|
||||||
|
↓
|
||||||
|
5. setContext command 수신 → sendVoiceIntents() 자동 호출
|
||||||
|
↓
|
||||||
|
6. UseIME Intent 등록 완료
|
||||||
|
↓
|
||||||
|
7. 사용자가 마이크 버튼 클릭 → VoiceInputOverlay 표시
|
||||||
|
↓
|
||||||
|
8. 사용자가 음성 발화 (예: "iPhone")
|
||||||
|
↓
|
||||||
|
9. performAction command 수신:
|
||||||
|
{
|
||||||
|
command: "performAction",
|
||||||
|
action: { intent: "UseIME", value: "iPhone" }
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
10. handleVoiceAction()에서 VOICE_STT_TEXT_RECEIVED dispatch
|
||||||
|
↓
|
||||||
|
11. useSearchVoice의 useEffect가 lastSTTText 감지
|
||||||
|
↓
|
||||||
|
12. onSTTText('iPhone') 호출
|
||||||
|
↓
|
||||||
|
13. SearchPanel의 handleSTTText에서:
|
||||||
|
- setSearchQuery('iPhone')
|
||||||
|
- getSearch() dispatch
|
||||||
|
- VoiceInputOverlay 닫기
|
||||||
|
|
||||||
|
Global Voice Search
|
||||||
|
|
||||||
|
1. 사용자가 리모컨 음성 버튼으로 "Search for iPhone on Shop Time" 발화
|
||||||
|
↓
|
||||||
|
2. webOS가 앱 실행하면서 webOSRelaunch 이벤트 발생
|
||||||
|
↓
|
||||||
|
3. App.js의 handleRelaunchEvent에서:
|
||||||
|
launchParams = {
|
||||||
|
intent: "SearchContent",
|
||||||
|
intentParam: "iPhone",
|
||||||
|
languageCode: "en-US"
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
4. SearchPanel을 pushPanel하면서 panelInfo에 전달:
|
||||||
|
{
|
||||||
|
voiceSearch: true,
|
||||||
|
searchVal: "iPhone",
|
||||||
|
languageCode: "en-US"
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
5. SearchPanel이 마운트되면서 panelInfo.voiceSearch 감지
|
||||||
|
↓
|
||||||
|
6. 자동으로 getSearch('iPhone') dispatch
|
||||||
|
|
||||||
|
---
|
||||||
|
📝 체크리스트
|
||||||
|
|
||||||
|
각 단계가 완료되었는지 확인해보세요:
|
||||||
|
|
||||||
|
● ---
|
||||||
|
🔑 핵심 포인트
|
||||||
|
|
||||||
|
1. UseIME이 STT 텍스트를 받는 핵심
|
||||||
|
|
||||||
|
// Intent 등록
|
||||||
|
{
|
||||||
|
intent: "UseIME",
|
||||||
|
supportAsrOnly: true // 순수 STT만 (NLP 불필요)
|
||||||
|
}
|
||||||
|
|
||||||
|
// performAction 수신
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
intent: "UseIME",
|
||||||
|
value: "사용자가 말한 텍스트" // ⭐ 이게 핵심!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Voice 등록은 Foreground 패널에서만
|
||||||
|
|
||||||
|
- SearchPanel이 isOnTop = true일 때만 registerVoiceFramework() 호출
|
||||||
|
- 다른 패널로 이동하면 unregisterVoiceFramework() 호출
|
||||||
|
- 이미 VoicePanel에서 기본 구조는 구현되어 있으므로, 이를 SearchPanel에 복사/적용하면 됨
|
||||||
|
|
||||||
|
3. VoiceInputOverlay는 UI만 담당
|
||||||
|
|
||||||
|
- VoiceInputOverlay는 시각적 피드백(prompt/listening)만 표시
|
||||||
|
- 실제 voice framework 등록은 SearchPanel에서 담당
|
||||||
|
- Redux의 lastSTTText 변경을 감지해서 UI 업데이트
|
||||||
|
|
||||||
|
4. Redux를 통한 상태 공유
|
||||||
|
|
||||||
|
registerVoiceFramework()
|
||||||
|
↓
|
||||||
|
voiceTicket 수신 → Redux에 저장
|
||||||
|
↓
|
||||||
|
performAction (UseIME) 수신 → Redux에 lastSTTText 저장
|
||||||
|
↓
|
||||||
|
SearchPanel의 useEffect가 lastSTTText 감지
|
||||||
|
↓
|
||||||
|
handleSTTText() 호출 → 검색 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
🚀 다음 단계
|
||||||
|
|
||||||
|
1. 먼저 voiceActions.js 수정 - UseIME Intent 추가 (가장 중요!)
|
||||||
|
2. actionTypes 및 reducer 업데이트 - STT 텍스트 state 관리
|
||||||
|
3. useSearchVoice Hook 구현 - 재사용 가능한 hook
|
||||||
|
4. SearchPanel 통합 - useSearchVoice 사용
|
||||||
|
5. VoiceInputOverlay 개선 - Redux 연결
|
||||||
|
6. App.js handleRelaunch - Global Voice Search
|
||||||
|
7. 테스트 - VoicePanel에서 먼저 테스트 후 SearchPanel 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
💡 디버깅 팁
|
||||||
|
|
||||||
|
Voice 등록 확인
|
||||||
|
|
||||||
|
// VoicePanel에서 "Register" 버튼 클릭
|
||||||
|
// Logs에서 확인:
|
||||||
|
// 1. REQUEST: Register Voice Framework
|
||||||
|
// 2. RESPONSE: { returnValue: true, subscribed: true }
|
||||||
|
// 3. COMMAND: setContext Command Received (voiceTicket 있음)
|
||||||
|
// 4. REQUEST: Set Voice Context (UseIME intent 확인)
|
||||||
|
// 5. RESPONSE: Set Voice Context Success
|
||||||
|
|
||||||
|
STT 텍스트 수신 확인
|
||||||
|
|
||||||
|
// 마이크 버튼 클릭 후 발화하면:
|
||||||
|
// Logs에서 확인:
|
||||||
|
// 1. COMMAND: performAction Command Received
|
||||||
|
// 2. action: { intent: "UseIME", value: "발화 텍스트" }
|
||||||
|
// 3. Redux devtools에서 VOICE_STT_TEXT_RECEIVED 액션 확인
|
||||||
|
|
||||||
|
---
|
||||||
881
com.twin.app.shoptime/vui-react.md
Normal file
881
com.twin.app.shoptime/vui-react.md
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
# [251015] React 프로젝트 VUI 구현 완벽 가이드
|
||||||
|
|
||||||
|
## 📚 목차
|
||||||
|
- [개요](#개요)
|
||||||
|
- [1. VUI 핵심 개념](#1-vui-핵심-개념)
|
||||||
|
- [2. voiceTicket과 STT 텍스트 수신 원리](#2-voiceticket과-stt-텍스트-수신-원리)
|
||||||
|
- [3. handleRelaunch 구현](#3-handlerelaunch-구현)
|
||||||
|
- [4. Luna Service 래퍼 구현](#4-luna-service-래퍼-구현)
|
||||||
|
- [5. Custom Hook 구현](#5-custom-hook-구현)
|
||||||
|
- [6. React 컴포넌트 통합](#6-react-컴포넌트-통합)
|
||||||
|
- [7. 전체 구현 체크리스트](#7-전체-구현-체크리스트)
|
||||||
|
- [8. 트러블슈팅](#8-트러블슈팅)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
이 문서는 React 기반 webOS TV 앱에서 **Voice User Interface (VUI)** 기능을 구현하는 완벽한 가이드입니다.
|
||||||
|
|
||||||
|
### 문서 목적
|
||||||
|
- ✅ voiceTicket의 정확한 역할 이해
|
||||||
|
- ✅ STT(Speech-to-Text) 텍스트를 받는 방법 명확히 설명
|
||||||
|
- ✅ handleRelaunch 구현 예시 제공
|
||||||
|
- ✅ 전체 React 프로젝트 구조에서의 구현 방법 안내
|
||||||
|
|
||||||
|
### 지원 환경
|
||||||
|
- **webOS 버전**: 5.0 MR2 이상 (2020년형 TV 이후)
|
||||||
|
- **React 버전**: 16.8+ (Hooks 지원)
|
||||||
|
- **Luna Service**: `com.webos.service.voiceconductor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. VUI 핵심 개념
|
||||||
|
|
||||||
|
### 1.1 VUI 기능 분류
|
||||||
|
|
||||||
|
webOS VUI는 크게 2가지 유형으로 나뉩니다:
|
||||||
|
|
||||||
|
#### A. **Global Voice Search (전역 음성 검색)**
|
||||||
|
- 앱이 **백그라운드/종료 상태**에서도 동작
|
||||||
|
- 사용자: "Search for iPhone on Shop Time"
|
||||||
|
- 시스템이 앱을 실행하고 `intent`, `intentParam` 전달
|
||||||
|
- **handleRelaunch**를 통해 파라미터 수신
|
||||||
|
|
||||||
|
#### B. **Foreground Voice Input (포그라운드 음성 입력)**
|
||||||
|
- 앱이 **포그라운드**일 때만 동작
|
||||||
|
- 사용자가 🎤 버튼을 누르고 발화
|
||||||
|
- **VoiceConductor Service**와 직접 통신
|
||||||
|
- STT 텍스트를 실시간으로 수신
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. voiceTicket과 STT 텍스트 수신 원리
|
||||||
|
|
||||||
|
### 2.1 voiceTicket이란?
|
||||||
|
|
||||||
|
**voiceTicket**은 Voice Framework와 통신하기 위한 **인증 토큰**입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[앱] ---register---> [Voice Framework]
|
||||||
|
<--- voiceTicket 발급 ---
|
||||||
|
|
||||||
|
이후 모든 API 호출 시 voiceTicket 필요:
|
||||||
|
- setContext (Intent 등록)
|
||||||
|
- reportActionResult (결과 보고)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 STT 텍스트 수신 메커니즘 ⭐
|
||||||
|
|
||||||
|
**핵심 원리**: `/interactor/register`를 `subscribe: true`로 호출하면, **음성 명령이 있을 때마다 같은 콜백이 계속 호출됩니다!**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ❌ 잘못된 이해: voiceTicket으로 텍스트를 조회한다?
|
||||||
|
// ⭐ 올바른 이해: subscribe 모드의 onSuccess가 계속 호출되며 텍스트가 전달된다!
|
||||||
|
|
||||||
|
webOS.service.request('luna://com.webos.service.voiceconductor', {
|
||||||
|
method: '/interactor/register',
|
||||||
|
parameters: {
|
||||||
|
type: 'foreground',
|
||||||
|
subscribe: true // ⚠️ 이것이 핵심!
|
||||||
|
},
|
||||||
|
onSuccess: function(response) {
|
||||||
|
// 최초 1회: voiceTicket 발급
|
||||||
|
if (response.voiceTicket) {
|
||||||
|
voiceTicket = response.voiceTicket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자가 발화할 때마다 이 콜백이 다시 호출됨!
|
||||||
|
if (response.command === 'performAction') {
|
||||||
|
// ✅ 여기서 STT 텍스트 수신!
|
||||||
|
console.log('STT 텍스트:', response.action.value);
|
||||||
|
console.log('Intent:', response.action.intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 전체 흐름도
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. /interactor/register (subscribe: true) │
|
||||||
|
│ → onSuccess에서 voiceTicket 저장 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 2. /interactor/setContext │
|
||||||
|
│ → voiceTicket + UseIME intent 등록 │
|
||||||
|
│ → Voice Framework에 "음성 입력 받을 준비 됨" 알림 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 3. 사용자가 리모컨 🎤 버튼 누르고 발화 │
|
||||||
|
│ 예: "iPhone" │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 4. /interactor/register의 onSuccess가 다시 호출됨! ✅ │
|
||||||
|
│ response.command === "performAction" │
|
||||||
|
│ response.action.intent === "UseIME" │
|
||||||
|
│ response.action.value === "iPhone" (STT 텍스트!) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 5. /interactor/reportActionResult │
|
||||||
|
│ → Voice Framework에 처리 완료 보고 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. handleRelaunch 구현
|
||||||
|
|
||||||
|
### 3.1 Global Voice Search 파라미터 수신
|
||||||
|
|
||||||
|
`appinfo.json` 설정:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "com.lgshop.app",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"inAppVoiceIntent": {
|
||||||
|
"contentTarget": {
|
||||||
|
"intent": "$INTENT",
|
||||||
|
"intentParam": "$INTENT_PARAM",
|
||||||
|
"languageCode": "$LANG_CODE"
|
||||||
|
},
|
||||||
|
"voiceConfig": {
|
||||||
|
"supportedIntent": ["SearchContent", "PlayContent"],
|
||||||
|
"supportedVoiceLanguage": ["en-US", "ko-KR"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 App.js에서 handleRelaunch 구현
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/App/App.js
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { pushPanel } from '../actions/panelActions';
|
||||||
|
import { panel_names } from '../utils/Config';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// ✅ handleRelaunchEvent 구현
|
||||||
|
const handleRelaunchEvent = useCallback(() => {
|
||||||
|
console.log("handleRelaunchEvent started");
|
||||||
|
|
||||||
|
const launchParams = getLaunchParams();
|
||||||
|
clearLaunchParams();
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ✅ Voice Intent 처리 (최우선)
|
||||||
|
// ========================================
|
||||||
|
if (launchParams?.intent) {
|
||||||
|
const { intent, intentParam, languageCode } = launchParams;
|
||||||
|
console.log("[Voice Intent]", { intent, intentParam, languageCode });
|
||||||
|
|
||||||
|
// SearchContent 또는 PlayContent intent 처리
|
||||||
|
if (intent === "SearchContent" || intent === "PlayContent") {
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.SEARCH_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
voiceSearch: true, // 음성 검색 플래그
|
||||||
|
searchVal: intentParam, // 검색어
|
||||||
|
languageCode: languageCode,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 로깅 (선택사항)
|
||||||
|
console.log(`[VUI] Opening SearchPanel with query: ${intentParam}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 기존 deeplink 처리
|
||||||
|
// ========================================
|
||||||
|
if (launchParams?.contentTarget) {
|
||||||
|
console.log("[DeepLink]", launchParams.contentTarget);
|
||||||
|
dispatch(handleDeepLink(launchParams.contentTarget));
|
||||||
|
}
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ✅ webOSRelaunch 이벤트 리스너 등록
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("webOSRelaunch", handleRelaunchEvent);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("webOSRelaunch", handleRelaunchEvent);
|
||||||
|
};
|
||||||
|
}, [handleRelaunchEvent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{/* 앱 컴포넌트 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Launch 파라미터 헬퍼 함수
|
||||||
|
function getLaunchParams() {
|
||||||
|
// PalmSystem에서 launch params 추출
|
||||||
|
if (window.PalmSystem) {
|
||||||
|
const params = JSON.parse(window.PalmSystem.launchParams || '{}');
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLaunchParams() {
|
||||||
|
if (window.PalmSystem) {
|
||||||
|
window.PalmSystem.launchParams = '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 SearchPanel에서 voiceSearch 플래그 처리
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/views/SearchPanel/SearchPanel.jsx
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
function SearchPanel({ panelInfo, isOnTop }) {
|
||||||
|
// ✅ 음성 검색으로 패널이 열렸을 때 자동 검색
|
||||||
|
useEffect(() => {
|
||||||
|
if (panelInfo?.voiceSearch && panelInfo?.searchVal) {
|
||||||
|
console.log("[SearchPanel] Voice search triggered:", panelInfo.searchVal);
|
||||||
|
|
||||||
|
// 자동으로 검색 실행
|
||||||
|
dispatch(
|
||||||
|
getSearch({
|
||||||
|
service: "com.lgshop.app",
|
||||||
|
query: panelInfo.searchVal,
|
||||||
|
domain: "theme,show,item",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [panelInfo?.voiceSearch, panelInfo?.searchVal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* SearchPanel UI */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchPanel;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Luna Service 래퍼 구현
|
||||||
|
|
||||||
|
### 4.1 voiceconductor.js 생성
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/lunaSend/voiceconductor.js
|
||||||
|
import LS2Request from "./LS2Request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoiceConductor 서비스: Foreground 앱의 음성 명령 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 현재 활성화된 voiceTicket
|
||||||
|
let currentVoiceTicket = null;
|
||||||
|
let voiceHandlerRef = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice Framework에 Foreground 앱으로 등록
|
||||||
|
*
|
||||||
|
* @param {Function} onCommand - performAction 수신 시 호출되는 콜백
|
||||||
|
* @param {Function} onSuccess - 등록 성공 시 콜백
|
||||||
|
* @param {Function} onFailure - 등록 실패 시 콜백
|
||||||
|
* @returns {Object} LS2Request 인스턴스
|
||||||
|
*/
|
||||||
|
export const registerVoiceConductor = ({ onCommand, onSuccess, onFailure }) => {
|
||||||
|
// ========================================
|
||||||
|
// Mock 처리 (브라우저 환경)
|
||||||
|
// ========================================
|
||||||
|
if (typeof window === "object" && !window.PalmSystem) {
|
||||||
|
console.log("[VoiceConductor] MOCK registerVoiceConductor");
|
||||||
|
const mockTicket = "mock-voice-ticket-" + Date.now();
|
||||||
|
currentVoiceTicket = mockTicket;
|
||||||
|
onSuccess && onSuccess({ returnValue: true, voiceTicket: mockTicket });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 기존 구독 취소
|
||||||
|
// ========================================
|
||||||
|
if (voiceHandlerRef) {
|
||||||
|
voiceHandlerRef.cancel();
|
||||||
|
voiceHandlerRef = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Voice Framework 등록 (subscribe: true!)
|
||||||
|
// ========================================
|
||||||
|
voiceHandlerRef = new LS2Request().send({
|
||||||
|
service: "luna://com.webos.service.voiceconductor",
|
||||||
|
method: "/interactor/register",
|
||||||
|
subscribe: true, // ⚠️ 핵심!
|
||||||
|
parameters: {
|
||||||
|
type: "foreground",
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
console.log("[VoiceConductor] register response:", res);
|
||||||
|
|
||||||
|
// ✅ 최초 등록 성공: voiceTicket 저장
|
||||||
|
if (res.voiceTicket) {
|
||||||
|
currentVoiceTicket = res.voiceTicket;
|
||||||
|
console.log("[VoiceConductor] voiceTicket issued:", currentVoiceTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ performAction 수신 처리 (STT 텍스트!)
|
||||||
|
if (res.command === "performAction") {
|
||||||
|
console.log("[VoiceConductor] performAction received:", res.action);
|
||||||
|
onCommand && onCommand(res.action, res.voiceTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최초 등록 성공 콜백
|
||||||
|
if (res.returnValue && res.voiceTicket && !res.command) {
|
||||||
|
onSuccess && onSuccess(res);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure: (err) => {
|
||||||
|
console.error("[VoiceConductor] register failed:", err);
|
||||||
|
currentVoiceTicket = null;
|
||||||
|
onFailure && onFailure(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return voiceHandlerRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice Framework에 현재 화면에서 처리 가능한 Intent 등록
|
||||||
|
*
|
||||||
|
* @param {Array} inAppIntents - Intent 배열
|
||||||
|
* @param {Object} callbacks - onSuccess, onFailure 콜백
|
||||||
|
* @returns {Object} LS2Request 인스턴스
|
||||||
|
*/
|
||||||
|
export const setVoiceContext = (inAppIntents, { onSuccess, onFailure }) => {
|
||||||
|
// Mock 처리
|
||||||
|
if (typeof window === "object" && !window.PalmSystem) {
|
||||||
|
console.log("[VoiceConductor] MOCK setVoiceContext:", inAppIntents);
|
||||||
|
onSuccess && onSuccess({ returnValue: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceTicket 검증
|
||||||
|
if (!currentVoiceTicket) {
|
||||||
|
console.warn("[VoiceConductor] No voiceTicket. Call registerVoiceConductor first.");
|
||||||
|
onFailure && onFailure({ returnValue: false, errorText: "No voiceTicket" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LS2Request().send({
|
||||||
|
service: "luna://com.webos.service.voiceconductor",
|
||||||
|
method: "/interactor/setContext",
|
||||||
|
parameters: {
|
||||||
|
voiceTicket: currentVoiceTicket,
|
||||||
|
inAppIntents: inAppIntents,
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
console.log("[VoiceConductor] setContext success:", res);
|
||||||
|
onSuccess && onSuccess(res);
|
||||||
|
},
|
||||||
|
onFailure: (err) => {
|
||||||
|
console.error("[VoiceConductor] setContext failed:", err);
|
||||||
|
onFailure && onFailure(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice 명령 처리 결과를 Voice Framework에 보고
|
||||||
|
*
|
||||||
|
* @param {Boolean} result - 성공 여부
|
||||||
|
* @param {String} utterance - TTS로 읽을 피드백 메시지
|
||||||
|
* @param {String} exception - 에러 타입
|
||||||
|
* @returns {Object} LS2Request 인스턴스
|
||||||
|
*/
|
||||||
|
export const reportActionResult = ({ result, utterance, exception, onSuccess, onFailure }) => {
|
||||||
|
// Mock 처리
|
||||||
|
if (typeof window === "object" && !window.PalmSystem) {
|
||||||
|
console.log("[VoiceConductor] MOCK reportActionResult:", { result, utterance, exception });
|
||||||
|
onSuccess && onSuccess({ returnValue: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentVoiceTicket) {
|
||||||
|
console.warn("[VoiceConductor] No voiceTicket for reportActionResult.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedback = {};
|
||||||
|
if (utterance || exception) {
|
||||||
|
feedback.voiceUi = {};
|
||||||
|
if (utterance) feedback.voiceUi.systemUtterance = utterance;
|
||||||
|
if (exception) feedback.voiceUi.exception = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LS2Request().send({
|
||||||
|
service: "luna://com.webos.service.voiceconductor",
|
||||||
|
method: "/interactor/reportActionResult",
|
||||||
|
parameters: {
|
||||||
|
voiceTicket: currentVoiceTicket,
|
||||||
|
result: result,
|
||||||
|
...(Object.keys(feedback).length > 0 && { feedback }),
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
console.log("[VoiceConductor] reportActionResult success:", res);
|
||||||
|
onSuccess && onSuccess(res);
|
||||||
|
},
|
||||||
|
onFailure: (err) => {
|
||||||
|
console.error("[VoiceConductor] reportActionResult failed:", err);
|
||||||
|
onFailure && onFailure(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice Framework 등록 해제
|
||||||
|
*/
|
||||||
|
export const unregisterVoiceConductor = () => {
|
||||||
|
if (voiceHandlerRef) {
|
||||||
|
console.log("[VoiceConductor] unregister");
|
||||||
|
voiceHandlerRef.cancel();
|
||||||
|
voiceHandlerRef = null;
|
||||||
|
}
|
||||||
|
currentVoiceTicket = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 voiceTicket 가져오기
|
||||||
|
*/
|
||||||
|
export const getVoiceTicket = () => currentVoiceTicket;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 lunaSend/index.js에 export 추가
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/lunaSend/index.js
|
||||||
|
import { LS2RequestSingleton } from './LS2RequestSingleton';
|
||||||
|
|
||||||
|
export * from './account';
|
||||||
|
export * from './common';
|
||||||
|
export * from './voiceconductor'; // ✅ 추가
|
||||||
|
|
||||||
|
export const cancelReq = (instanceName) => {
|
||||||
|
let r = LS2RequestSingleton.instance(instanceName);
|
||||||
|
if (r) {
|
||||||
|
r.cancel();
|
||||||
|
r.cancelled = false;
|
||||||
|
LS2RequestSingleton.deleteInstance(instanceName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Custom Hook 구현
|
||||||
|
|
||||||
|
### 5.1 useVoiceConductor.js 생성
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/hooks/useVoiceConductor.js
|
||||||
|
import { useEffect, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
registerVoiceConductor,
|
||||||
|
setVoiceContext,
|
||||||
|
reportActionResult,
|
||||||
|
unregisterVoiceConductor,
|
||||||
|
} from "../lunaSend/voiceconductor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoiceConductor Hook: 음성 명령 처리를 위한 React Hook
|
||||||
|
*
|
||||||
|
* @param {Boolean} isActive - 패널이 활성화(foreground)되었는지 여부
|
||||||
|
* @param {Function} onVoiceInput - STT 텍스트 수신 시 호출되는 콜백
|
||||||
|
* @param {Array} inAppIntents - 등록할 intent 목록 (선택, 기본값: UseIME)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // SearchPanel에서 사용
|
||||||
|
* useVoiceConductor(isOnTop, (text) => {
|
||||||
|
* console.log('음성 텍스트:', text);
|
||||||
|
* setSearchQuery(text);
|
||||||
|
* handleSearchSubmit(text);
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export const useVoiceConductor = (isActive, onVoiceInput, inAppIntents = null) => {
|
||||||
|
const isActiveRef = useRef(isActive);
|
||||||
|
|
||||||
|
// isActive 상태를 ref로 관리 (콜백에서 최신 값 참조)
|
||||||
|
useEffect(() => {
|
||||||
|
isActiveRef.current = isActive;
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// performAction 수신 처리
|
||||||
|
// ========================================
|
||||||
|
const handleCommand = useCallback(
|
||||||
|
(action, voiceTicket) => {
|
||||||
|
// 패널이 활성화되지 않았으면 무시
|
||||||
|
if (!isActiveRef.current) {
|
||||||
|
console.log("[useVoiceConductor] Not active, ignoring command");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[useVoiceConductor] handleCommand:", action);
|
||||||
|
|
||||||
|
const { intent, value } = action;
|
||||||
|
|
||||||
|
// ✅ UseIME: 음성 입력 텍스트 전달
|
||||||
|
if (intent === "UseIME" && value) {
|
||||||
|
console.log("[useVoiceConductor] STT 텍스트:", value);
|
||||||
|
onVoiceInput && onVoiceInput(value);
|
||||||
|
|
||||||
|
// 성공 피드백 보고
|
||||||
|
reportActionResult({
|
||||||
|
result: true,
|
||||||
|
utterance: `Searching for ${value}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 intent 처리 가능 (Select, Scroll 등)
|
||||||
|
// if (intent === "Select" && action.itemId) { ... }
|
||||||
|
},
|
||||||
|
[onVoiceInput]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Voice Framework 등록 및 Intent 설정
|
||||||
|
// ========================================
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
console.log("[useVoiceConductor] Registering VoiceConductor...");
|
||||||
|
|
||||||
|
// 1. Voice Framework 등록
|
||||||
|
const handler = registerVoiceConductor({
|
||||||
|
onCommand: handleCommand,
|
||||||
|
onSuccess: (res) => {
|
||||||
|
console.log("[useVoiceConductor] Registered, voiceTicket:", res.voiceTicket);
|
||||||
|
|
||||||
|
// 2. Intent 등록 (UseIME)
|
||||||
|
const defaultIntents = [
|
||||||
|
{
|
||||||
|
intent: "UseIME",
|
||||||
|
supportAsrOnly: true, // STT만 사용 (NLP 불필요)
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const intentsToRegister = inAppIntents || defaultIntents;
|
||||||
|
|
||||||
|
setVoiceContext(intentsToRegister, {
|
||||||
|
onSuccess: (contextRes) => {
|
||||||
|
console.log("[useVoiceConductor] Context set successfully");
|
||||||
|
},
|
||||||
|
onFailure: (err) => {
|
||||||
|
console.error("[useVoiceConductor] Failed to set context:", err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onFailure: (err) => {
|
||||||
|
console.error("[useVoiceConductor] Registration failed:", err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Cleanup: 패널이 닫히거나 비활성화될 때
|
||||||
|
return () => {
|
||||||
|
console.log("[useVoiceConductor] Unregistering...");
|
||||||
|
unregisterVoiceConductor();
|
||||||
|
};
|
||||||
|
}, [isActive, handleCommand, inAppIntents]);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. React 컴포넌트 통합
|
||||||
|
|
||||||
|
### 6.1 SearchPanel.jsx에서 사용
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/views/SearchPanel/SearchPanel.jsx
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { getSearch, resetSearch } from "../../actions/searchActions";
|
||||||
|
import { useVoiceConductor } from "../../hooks/useVoiceConductor";
|
||||||
|
import TInput from "../../components/TInput/TInput";
|
||||||
|
|
||||||
|
function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [searchQuery, setSearchQuery] = useState(panelInfo?.searchVal || "");
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ✅ Voice Input 처리 콜백
|
||||||
|
// ========================================
|
||||||
|
const handleVoiceInput = useCallback(
|
||||||
|
(voiceText) => {
|
||||||
|
console.log("[SearchPanel] Voice input received:", voiceText);
|
||||||
|
|
||||||
|
// 검색어 설정
|
||||||
|
setSearchQuery(voiceText);
|
||||||
|
|
||||||
|
// 즉시 검색 수행
|
||||||
|
if (voiceText && voiceText.trim()) {
|
||||||
|
dispatch(
|
||||||
|
getSearch({
|
||||||
|
service: "com.lgshop.app",
|
||||||
|
query: voiceText.trim(),
|
||||||
|
domain: "theme,show,item",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ✅ VoiceConductor Hook 활성화
|
||||||
|
// ========================================
|
||||||
|
useVoiceConductor(isOnTop, handleVoiceInput);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ✅ Global Voice Search 처리
|
||||||
|
// ========================================
|
||||||
|
useEffect(() => {
|
||||||
|
if (panelInfo?.voiceSearch && panelInfo?.searchVal) {
|
||||||
|
console.log("[SearchPanel] Global voice search:", panelInfo.searchVal);
|
||||||
|
setSearchQuery(panelInfo.searchVal);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
getSearch({
|
||||||
|
service: "com.lgshop.app",
|
||||||
|
query: panelInfo.searchVal,
|
||||||
|
domain: "theme,show,item",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [panelInfo?.voiceSearch, panelInfo?.searchVal]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 수동 검색 처리
|
||||||
|
// ========================================
|
||||||
|
const handleSearchSubmit = useCallback(
|
||||||
|
(query) => {
|
||||||
|
if (query && query.trim()) {
|
||||||
|
dispatch(
|
||||||
|
getSearch({
|
||||||
|
service: "com.lgshop.app",
|
||||||
|
query: query.trim(),
|
||||||
|
domain: "theme,show,item",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-panel">
|
||||||
|
<TInput
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.value)}
|
||||||
|
onIconClick={() => handleSearchSubmit(searchQuery)}
|
||||||
|
placeholder="Say or type to search..." // 음성 입력 안내
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 검색 결과 표시 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchPanel;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 전체 구현 체크리스트
|
||||||
|
|
||||||
|
### Phase 1: Global Voice Search
|
||||||
|
|
||||||
|
- [ ] **appinfo.json 수정**
|
||||||
|
- [ ] `inAppVoiceIntent` 섹션 추가
|
||||||
|
- [ ] `supportedIntent`에 `SearchContent`, `PlayContent` 추가
|
||||||
|
- [ ] `supportedVoiceLanguage` 설정
|
||||||
|
|
||||||
|
- [ ] **App.js 수정**
|
||||||
|
- [ ] `handleRelaunchEvent` 함수 구현
|
||||||
|
- [ ] `webOSRelaunch` 이벤트 리스너 등록
|
||||||
|
- [ ] Voice Intent 파라미터 파싱
|
||||||
|
- [ ] SearchPanel 열기 및 검색 실행
|
||||||
|
|
||||||
|
- [ ] **SearchPanel 수정**
|
||||||
|
- [ ] `panelInfo.voiceSearch` 플래그 확인
|
||||||
|
- [ ] 자동 검색 로직 추가
|
||||||
|
|
||||||
|
### Phase 2: Foreground Voice Input
|
||||||
|
|
||||||
|
- [ ] **Luna Service 래퍼 생성**
|
||||||
|
- [ ] `src/lunaSend/voiceconductor.js` 생성
|
||||||
|
- [ ] `registerVoiceConductor` 함수 구현
|
||||||
|
- [ ] `setVoiceContext` 함수 구현
|
||||||
|
- [ ] `reportActionResult` 함수 구현
|
||||||
|
- [ ] `unregisterVoiceConductor` 함수 구현
|
||||||
|
|
||||||
|
- [ ] **lunaSend/index.js 수정**
|
||||||
|
- [ ] voiceconductor export 추가
|
||||||
|
|
||||||
|
- [ ] **Custom Hook 생성**
|
||||||
|
- [ ] `src/hooks/useVoiceConductor.js` 생성
|
||||||
|
- [ ] subscribe 모드 콜백 처리
|
||||||
|
- [ ] Intent 등록 로직
|
||||||
|
- [ ] Cleanup 로직
|
||||||
|
|
||||||
|
- [ ] **SearchPanel 통합**
|
||||||
|
- [ ] `useVoiceConductor` Hook 추가
|
||||||
|
- [ ] `handleVoiceInput` 콜백 구현
|
||||||
|
- [ ] STT 텍스트로 자동 검색
|
||||||
|
|
||||||
|
### Phase 3: 테스트
|
||||||
|
|
||||||
|
- [ ] **브라우저 Mock 테스트**
|
||||||
|
- [ ] Console에 MOCK 로그 확인
|
||||||
|
- [ ] 수동 트리거로 동작 확인
|
||||||
|
|
||||||
|
- [ ] **Emulator 테스트**
|
||||||
|
- [ ] 앱 빌드 및 설치 (`npm run pack`)
|
||||||
|
- [ ] Global Voice Search 테스트
|
||||||
|
- [ ] Foreground Voice Input 테스트
|
||||||
|
|
||||||
|
- [ ] **실제 TV 테스트**
|
||||||
|
- [ ] Voice Remote로 명령 테스트
|
||||||
|
- [ ] 다국어 테스트 (en-US, ko-KR)
|
||||||
|
- [ ] Luna Service 로그 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 트러블슈팅
|
||||||
|
|
||||||
|
### 문제 1: voiceTicket이 null로 나옴
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- `subscribe: true`를 설정하지 않음
|
||||||
|
- Luna Service 호출 실패
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```javascript
|
||||||
|
// ✅ subscribe: true 확인
|
||||||
|
registerVoiceConductor({
|
||||||
|
subscribe: true, // 필수!
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 2: STT 텍스트를 받지 못함
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- `setContext`에서 UseIME intent를 등록하지 않음
|
||||||
|
- `performAction` 이벤트 처리 누락
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```javascript
|
||||||
|
// 1. Intent 등록 확인
|
||||||
|
setVoiceContext([{
|
||||||
|
intent: "UseIME",
|
||||||
|
supportAsrOnly: true,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// 2. performAction 처리 확인
|
||||||
|
if (response.command === "performAction") {
|
||||||
|
console.log(response.action.value); // STT 텍스트
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 3: 패널이 닫혀도 음성 입력이 계속 처리됨
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- `unregisterVoiceConductor` 호출 누락
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```javascript
|
||||||
|
// useEffect cleanup에서 반드시 호출
|
||||||
|
useEffect(() => {
|
||||||
|
// ... 등록 로직
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregisterVoiceConductor(); // ✅ 필수!
|
||||||
|
};
|
||||||
|
}, [isActive]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제 4: 브라우저에서 에러 발생
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
- PalmSystem이 없는 환경에서 Luna Service 호출
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
```javascript
|
||||||
|
// Mock 처리 추가
|
||||||
|
if (typeof window === "object" && !window.PalmSystem) {
|
||||||
|
console.log("[VoiceConductor] MOCK mode");
|
||||||
|
// Mock 응답 반환
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 핵심 요약
|
||||||
|
|
||||||
|
### voiceTicket의 역할
|
||||||
|
- Voice Framework와 통신하기 위한 **인증 토큰**
|
||||||
|
- `/interactor/register` 최초 호출 시 발급
|
||||||
|
- 이후 `setContext`, `reportActionResult`에 필수
|
||||||
|
|
||||||
|
### STT 텍스트 수신 방법
|
||||||
|
1. `/interactor/register`를 **subscribe: true**로 호출
|
||||||
|
2. 최초 onSuccess에서 voiceTicket 저장
|
||||||
|
3. 사용자 발화 시 **같은 onSuccess가 다시 호출**됨
|
||||||
|
4. `response.command === "performAction"` 확인
|
||||||
|
5. `response.action.value`에 STT 텍스트 포함!
|
||||||
|
|
||||||
|
### 전체 흐름
|
||||||
|
```
|
||||||
|
register (subscribe: true)
|
||||||
|
→ voiceTicket 발급
|
||||||
|
→ setContext (UseIME 등록)
|
||||||
|
→ 사용자 발화
|
||||||
|
→ onSuccess 다시 호출 (performAction)
|
||||||
|
→ action.value = STT 텍스트!
|
||||||
|
→ reportActionResult
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 참고 문서
|
||||||
|
|
||||||
|
- **VUI 기본 가이드**: `docs/vui/VoiceUserInterface_ko.md`
|
||||||
|
- **SearchPanel VUI 구현**: `docs/vui/searchpanel-vui-implementation-guide.md`
|
||||||
|
- **테스트 시나리오**: `docs/vui/vui-test-scenarios.md`
|
||||||
|
- **Luna Service 가이드**: webOS Developer Portal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-10-15
|
||||||
|
**작성자**: AI Assistant
|
||||||
|
**버전**: 1.0.0
|
||||||
|
**관련 이슈**: React VUI Implementation Guide
|
||||||
Reference in New Issue
Block a user