🕐 커밋 시간: 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() 🔧 주요 변경 내용: • 타입 시스템 안정성 강화 • 핵심 비즈니스 로직 개선 • 개발 문서 및 가이드 개선
17 KiB
📋 구현 계획
● 이제 각 단계별로 자세히 설명드리겠습니다.
🔧 구체적인 구현 방법
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)
- SearchPanel이 foreground가 됨 (isOnTop = true) ↓
- useSearchVoice Hook이 registerVoiceFramework() dispatch ↓
- voiceActions.js에서 luna://com.webos.service.voiceconductor/interactor/register 호출 ↓
- voiceTicket 수신 ↓
- setContext command 수신 → sendVoiceIntents() 자동 호출 ↓
- UseIME Intent 등록 완료 ↓
- 사용자가 마이크 버튼 클릭 → VoiceInputOverlay 표시 ↓
- 사용자가 음성 발화 (예: "iPhone") ↓
- performAction command 수신: { command: "performAction", action: { intent: "UseIME", value: "iPhone" } } ↓
- handleVoiceAction()에서 VOICE_STT_TEXT_RECEIVED dispatch ↓
- useSearchVoice의 useEffect가 lastSTTText 감지 ↓
- onSTTText('iPhone') 호출 ↓
- SearchPanel의 handleSTTText에서:
- setSearchQuery('iPhone')
- getSearch() dispatch
- VoiceInputOverlay 닫기
Global Voice Search
- 사용자가 리모컨 음성 버튼으로 "Search for iPhone on Shop Time" 발화 ↓
- webOS가 앱 실행하면서 webOSRelaunch 이벤트 발생 ↓
- App.js의 handleRelaunchEvent에서: launchParams = { intent: "SearchContent", intentParam: "iPhone", languageCode: "en-US" } ↓
- SearchPanel을 pushPanel하면서 panelInfo에 전달: { voiceSearch: true, searchVal: "iPhone", languageCode: "en-US" } ↓
- SearchPanel이 마운트되면서 panelInfo.voiceSearch 감지 ↓
- 자동으로 getSearch('iPhone') dispatch
📝 체크리스트
각 단계가 완료되었는지 확인해보세요:
● --- 🔑 핵심 포인트
- UseIME이 STT 텍스트를 받는 핵심
// Intent 등록 { intent: "UseIME", supportAsrOnly: true // 순수 STT만 (NLP 불필요) }
// performAction 수신 { action: { intent: "UseIME", value: "사용자가 말한 텍스트" // ⭐ 이게 핵심! } }
- Voice 등록은 Foreground 패널에서만
- SearchPanel이 isOnTop = true일 때만 registerVoiceFramework() 호출
- 다른 패널로 이동하면 unregisterVoiceFramework() 호출
- 이미 VoicePanel에서 기본 구조는 구현되어 있으므로, 이를 SearchPanel에 복사/적용하면 됨
- VoiceInputOverlay는 UI만 담당
- VoiceInputOverlay는 시각적 피드백(prompt/listening)만 표시
- 실제 voice framework 등록은 SearchPanel에서 담당
- Redux의 lastSTTText 변경을 감지해서 UI 업데이트
- Redux를 통한 상태 공유
registerVoiceFramework() ↓ voiceTicket 수신 → Redux에 저장 ↓ performAction (UseIME) 수신 → Redux에 lastSTTText 저장 ↓ SearchPanel의 useEffect가 lastSTTText 감지 ↓ handleSTTText() 호출 → 검색 실행
🚀 다음 단계
- 먼저 voiceActions.js 수정 - UseIME Intent 추가 (가장 중요!)
- actionTypes 및 reducer 업데이트 - STT 텍스트 state 관리
- useSearchVoice Hook 구현 - 재사용 가능한 hook
- SearchPanel 통합 - useSearchVoice 사용
- VoiceInputOverlay 개선 - Redux 연결
- App.js handleRelaunch - Global Voice Search
- 테스트 - 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 액션 확인