From 9dd5897c249ca3a397a874d3a81dede8c26b8b05 Mon Sep 17 00:00:00 2001 From: optrader Date: Thu, 16 Oct 2025 10:26:18 +0900 Subject: [PATCH] [251016] fix: VoiceInputOverlay ShopperHouse connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ• 컀밋 μ‹œκ°„: 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() πŸ”§ μ£Όμš” λ³€κ²½ λ‚΄μš©: β€’ νƒ€μž… μ‹œμŠ€ν…œ μ•ˆμ •μ„± κ°•ν™” β€’ 핡심 λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 κ°œμ„  β€’ 개발 λ¬Έμ„œ 및 κ°€μ΄λ“œ κ°œμ„  --- .../src/actions/actionTypes.js | 1 + .../src/actions/searchActions.js | 1 + .../VoiceInputOverlay/VoiceInputOverlay.jsx | 115 ++- .../modes/VoiceListening.module.less | 24 +- .../modes/VoicePromptScreen.jsx | 30 +- com.twin.app.shoptime/vui-implement.md | 604 ++++++++++++ com.twin.app.shoptime/vui-react.md | 881 ++++++++++++++++++ 7 files changed, 1593 insertions(+), 63 deletions(-) create mode 100644 com.twin.app.shoptime/vui-implement.md create mode 100644 com.twin.app.shoptime/vui-react.md diff --git a/com.twin.app.shoptime/src/actions/actionTypes.js b/com.twin.app.shoptime/src/actions/actionTypes.js index 3661d3f8..ecfa24b6 100644 --- a/com.twin.app.shoptime/src/actions/actionTypes.js +++ b/com.twin.app.shoptime/src/actions/actionTypes.js @@ -293,4 +293,5 @@ export const types = { 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', // STT ν…μŠ€νŠΈ μˆ˜μ‹  }; diff --git a/com.twin.app.shoptime/src/actions/searchActions.js b/com.twin.app.shoptime/src/actions/searchActions.js index a35ae8b4..e08e43e6 100644 --- a/com.twin.app.shoptime/src/actions/searchActions.js +++ b/com.twin.app.shoptime/src/actions/searchActions.js @@ -112,6 +112,7 @@ export const getShopperHouseSearch = if (searchId) { params.searchid = searchId; } + console.log('[ShopperHouse] getShopperHouseSearch params: ', JSON.stringify(params)); TAxios(dispatch, getState, 'post', URLS.GET_SHOPPERHOUSE_SEARCH, {}, params, onSuccess, onFail); }; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx index 9955444e..9cf19753 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx @@ -6,9 +6,11 @@ import Spotlight from '@enact/spotlight'; import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator'; import Spottable from '@enact/spotlight/Spottable'; +import { useDispatch } from 'react-redux'; import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput'; import TFullPopup from '../../../components/TFullPopup/TFullPopup'; import micIcon from '../../../../assets/images/searchpanel/image-mic.png'; +import { getShopperHouseSearch } from '../../../actions/searchActions'; import css from './VoiceInputOverlay.module.less'; import VoicePromptScreen from './modes/VoicePromptScreen'; import VoiceListening from './modes/VoiceListening'; @@ -44,6 +46,7 @@ const VoiceInputOverlay = ({ onSearchChange, onSearchSubmit, }) => { + const dispatch = useDispatch(); const lastFocusedElement = useRef(null); const [inputFocus, setInputFocus] = useState(false); // λ‚΄λΆ€ λͺ¨λ“œ μƒνƒœ 관리 (prompt -> listening -> close) @@ -72,11 +75,52 @@ const VoiceInputOverlay = ({ } }, [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 = () => { switch (currentMode) { case VOICE_MODES.PROMPT: - return ; + return ( + + ); case VOICE_MODES.LISTENING: return ; case VOICE_MODES.MODE_3: @@ -86,7 +130,9 @@ const VoiceInputOverlay = ({ // μΆ”ν›„ MODE_4 μ»΄ν¬λ„ŒνŠΈ μΆ”κ°€ return
Mode 4 (Coming soon)
; default: - return ; + return ( + + ); } }; @@ -100,37 +146,43 @@ const VoiceInputOverlay = ({ }, []); // 마이크 λ²„νŠΌ 클릭 (λͺ¨λ“œ μ „ν™˜: prompt -> listening -> close) - const handleMicClick = useCallback((e) => { - console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode); + const handleMicClick = useCallback( + (e) => { + console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode); - // 이벀트 μ „νŒŒ λ°©μ§€ - dim λ ˆμ΄μ–΄μ˜ onClick μ‹€ν–‰ λ°©μ§€ - if (e && e.stopPropagation) { - e.stopPropagation(); - } - if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) { - e.nativeEvent.stopImmediatePropagation(); - } + // 이벀트 μ „νŒŒ λ°©μ§€ - dim λ ˆμ΄μ–΄μ˜ onClick μ‹€ν–‰ λ°©μ§€ + if (e && e.stopPropagation) { + e.stopPropagation(); + } + if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) { + e.nativeEvent.stopImmediatePropagation(); + } - if (currentMode === VOICE_MODES.PROMPT) { - // prompt λͺ¨λ“œμ—μ„œ 클릭 μ‹œ -> listening λͺ¨λ“œλ‘œ μ „ν™˜ - console.log('[VoiceInputOverlay] Switching to LISTENING mode'); - setCurrentMode(VOICE_MODES.LISTENING); - } else if (currentMode === VOICE_MODES.LISTENING) { - // listening λͺ¨λ“œμ—μ„œ 클릭 μ‹œ -> μ’…λ£Œ - console.log('[VoiceInputOverlay] Closing from LISTENING mode'); - onClose(); - } else { - // 기타 λͺ¨λ“œμ—μ„œλŠ” λ°”λ‘œ μ’…λ£Œ - console.log('[VoiceInputOverlay] Closing from other mode'); - onClose(); - } - }, [currentMode, onClose]); + if (currentMode === VOICE_MODES.PROMPT) { + // prompt λͺ¨λ“œμ—μ„œ 클릭 μ‹œ -> listening λͺ¨λ“œλ‘œ μ „ν™˜ + console.log('[VoiceInputOverlay] Switching to LISTENING mode'); + setCurrentMode(VOICE_MODES.LISTENING); + } else if (currentMode === VOICE_MODES.LISTENING) { + // listening λͺ¨λ“œμ—μ„œ 클릭 μ‹œ -> μ’…λ£Œ + console.log('[VoiceInputOverlay] Closing from LISTENING mode'); + onClose(); + } else { + // 기타 λͺ¨λ“œμ—μ„œλŠ” λ°”λ‘œ μ’…λ£Œ + console.log('[VoiceInputOverlay] Closing from other mode'); + onClose(); + } + }, + [currentMode, onClose] + ); // dim λ ˆμ΄μ–΄ 클릭 ν•Έλ“€λŸ¬ (마이크 λ²„νŠΌκ³Ό 뢄리) - const handleDimClick = useCallback((e) => { - console.log('[VoiceInputOverlay] dimBackground clicked'); - onClose(); - }, [onClose]); + const handleDimClick = useCallback( + (e) => { + console.log('[VoiceInputOverlay] dimBackground clicked'); + onClose(); + }, + [onClose] + ); return (
@@ -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 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