@@ -162,7 +214,8 @@ const VoiceInputOverlay = ({
icon={ICONS.search}
value={searchQuery}
onChange={onSearchChange}
- onIconClick={() => onSearchSubmit && onSearchSubmit(searchQuery)}
+ onKeyDown={handleInputKeyDown}
+ onIconClick={handleSearchSubmit}
spotlightId={INPUT_SPOTLIGHT_ID}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less
index 5eb52ce8..1912f895 100644
--- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less
+++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less
@@ -71,7 +71,7 @@
.bar2 {
width: 510px;
left: 0;
- background: @PRIMARY_COLOR_RED;
+ background: #FFB3B3;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.4s; // 가장 큰 막대 - 마지막
opacity: 0; // 애니메이션으로 제어
@@ -80,7 +80,7 @@
.bar3 {
width: 480px;
left: 15px;
- background: @PRIMARY_COLOR_RED;
+ background: #FF8080;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.2s;
opacity: 0;
@@ -89,7 +89,7 @@
.bar4 {
width: 390px;
left: 60px;
- background: @PRIMARY_COLOR_RED;
+ background: #FF6666;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.0s;
opacity: 0;
@@ -98,7 +98,7 @@
.bar5 {
width: 350px;
left: 80px;
- background: @PRIMARY_COLOR_RED;
+ background: #FF4D4D;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.8s;
opacity: 0;
@@ -107,7 +107,7 @@
.bar6 {
width: 320px;
left: 95px;
- background: @PRIMARY_COLOR_RED;
+ background: #FF3333;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.6s;
opacity: 0;
@@ -116,7 +116,7 @@
.bar7 {
width: 260px;
left: 125px;
- background: @PRIMARY_COLOR_RED;
+ background: #FF1A1A;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.4s;
opacity: 0;
@@ -125,7 +125,7 @@
.bar8 {
width: 200px;
left: 155px;
- background: @PRIMARY_COLOR_RED;
+ background: #FF0000;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.2s;
opacity: 0;
@@ -134,7 +134,7 @@
.bar9 {
width: 150px;
left: 180px;
- background: @PRIMARY_COLOR_RED;
+ background: #E00000;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.1s;
opacity: 0;
@@ -143,7 +143,7 @@
.bar10 {
width: 100px;
left: 205px;
- background: @PRIMARY_COLOR_RED;
+ background: #CC0000;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0s; // 가장 작은 막대 - 처음 시작
opacity: 0;
@@ -155,7 +155,11 @@
opacity: 0;
transform: scaleY(0);
}
- 50% {
+ 20% {
+ opacity: 1;
+ transform: scaleY(1);
+ }
+ 80% {
opacity: 1;
transform: scaleY(1);
}
diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx
index effdc39f..229e9845 100644
--- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx
+++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx
@@ -1,10 +1,8 @@
// src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx
-import React, { useEffect, useRef } from 'react';
+import React from 'react';
import PropTypes from 'prop-types';
-import { useDispatch, useSelector } from 'react-redux';
import Spottable from '@enact/spotlight/Spottable';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
-import { getShopperHouseSearch } from '../../../../actions/searchActions';
import css from './VoicePromptScreen.module.less';
const SpottableBubble = Spottable('div');
@@ -17,27 +15,14 @@ const PromptContainer = SpotlightContainerDecorator(
'div'
);
-const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => {
- 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 VoicePromptScreen = ({ title = 'Try saying', suggestions = [], onSuggestionClick }) => {
const handleBubbleClick = (suggestion, index) => {
- console.log(`Bubble clicked: ${suggestion}`, index);
+ console.log(`[VoicePromptScreen] Bubble clicked: ${suggestion}`, index);
- // suggestion에서 따옴표 제거하여 검색어 추출
- const query = suggestion.replace(/^["']|["']$/g, '').trim();
-
- // ShopperHouse API 호출
- dispatch(getShopperHouseSearch(query));
+ // 부모 컴포넌트로 suggestion 텍스트 전달 (API 호출은 부모에서 처리)
+ if (onSuggestionClick) {
+ onSuggestionClick(suggestion);
+ }
};
return (
@@ -66,6 +51,7 @@ const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => {
VoicePromptScreen.propTypes = {
title: PropTypes.string,
suggestions: PropTypes.arrayOf(PropTypes.string),
+ onSuggestionClick: PropTypes.func,
};
VoicePromptScreen.defaultProps = {
diff --git a/com.twin.app.shoptime/vui-implement.md b/com.twin.app.shoptime/vui-implement.md
new file mode 100644
index 00000000..782af476
--- /dev/null
+++ b/com.twin.app.shoptime/vui-implement.md
@@ -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 (
+
+
+ {/* 기존 SearchPanel UI */}
+ handleSearchSubmit(searchQuery)}
+ // ... props
+ />
+
+ {/* ⭐ 마이크 버튼 추가 (선택사항) */}
+
+ 🎤 Voice Search
+
+
+ {/* ⭐ Voice Overlay */}
+ setSearchQuery(e.value)}
+ onSearchSubmit={handleSearchSubmit}
+ suggestions={paginatedKeywords?.map(k => k.keyword) || []}
+ />
+
+ {/* 검색 결과 등 나머지 UI */}
+
+
+ );
+ }
+
+ ---
+ 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 (
+
+ {/* 기존 UI */}
+
+ {/* Voice 등록 상태 표시 (디버깅용, 나중에 제거 가능) */}
+ {__DEV__ && (
+
+ Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'}
+
+ )}
+
+ {/* 나머지 UI */}
+
+ );
+ };
+
+ ---
+ 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 (
+
+ {/* 앱 컴포넌트 */}
+
+ );
+ }
+
+ // ⭐ 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 액션 확인
+
+ ---
\ No newline at end of file
diff --git a/com.twin.app.shoptime/vui-react.md b/com.twin.app.shoptime/vui-react.md
new file mode 100644
index 00000000..cda910e8
--- /dev/null
+++ b/com.twin.app.shoptime/vui-react.md
@@ -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 (
+
+ {/* 앱 컴포넌트 */}
+
+ );
+}
+
+// ✅ 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 (
+
+ {/* SearchPanel UI */}
+
+ );
+}
+
+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 (
+
+ setSearchQuery(e.value)}
+ onIconClick={() => handleSearchSubmit(searchQuery)}
+ placeholder="Say or type to search..." // 음성 입력 안내
+ />
+
+ {/* 검색 결과 표시 */}
+
+ );
+}
+
+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